diff --git a/Cargo.lock b/Cargo.lock index c8e7775b..6f2cbedb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aead" version = "0.5.2" @@ -68,6 +74,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy 0.8.39", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -78,10 +98,25 @@ dependencies = [ ] [[package]] -name = "android-tzdata" -version = "0.1.1" +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" @@ -92,12 +127,71 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "arc-swap" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ded5f9a03ac8f24d1b8a25101ee812cd32cdc8c50a4c50237de2c4915850e73" +dependencies = [ + "rustversion", +] + [[package]] name = "argon2" version = "0.5.3" @@ -110,12 +204,232 @@ dependencies = [ "password-hash", ] +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + [[package]] name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "arrow" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e833808ff2d94ed40d9379848a950d995043c7fb3e81a30b383f4c6033821cc" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-csv", + "arrow-data", + "arrow-ipc", + "arrow-json", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", +] + +[[package]] +name = "arrow-arith" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad08897b81588f60ba983e3ca39bda2b179bdd84dced378e7df81a5313802ef8" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "num", +] + +[[package]] +name = "arrow-array" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8548ca7c070d8db9ce7aa43f37393e4bfcf3f2d3681df278490772fd1673d08d" +dependencies = [ + "ahash", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "half 2.4.1", + "hashbrown 0.16.0", + "num", +] + +[[package]] +name = "arrow-buffer" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e003216336f70446457e280807a73899dd822feaf02087d31febca1363e2fccc" +dependencies = [ + "bytes", + "half 2.4.1", + "num", +] + +[[package]] +name = "arrow-cast" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "919418a0681298d3a77d1a315f625916cb5678ad0d74b9c60108eb15fd083023" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "atoi", + "base64 0.22.1", + "chrono", + "half 2.4.1", + "lexical-core", + "num", + "ryu", +] + +[[package]] +name = "arrow-csv" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa9bf02705b5cf762b6f764c65f04ae9082c7cfc4e96e0c33548ee3f67012eb" +dependencies = [ + "arrow-array", + "arrow-cast", + "arrow-schema", + "chrono", + "csv", + "csv-core", + "regex", +] + +[[package]] +name = "arrow-data" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5c64fff1d142f833d78897a772f2e5b55b36cb3e6320376f0961ab0db7bd6d0" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half 2.4.1", + "num", +] + +[[package]] +name = "arrow-ipc" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d3594dcddccc7f20fd069bc8e9828ce37220372680ff638c5e00dea427d88f5" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "flatbuffers", +] + +[[package]] +name = "arrow-json" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88cf36502b64a127dc659e3b305f1d993a544eab0d48cce704424e62074dc04b" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-schema", + "chrono", + "half 2.4.1", + "indexmap", + "lexical-core", + "memchr", + "num", + "serde", + "serde_json", + "simdutf8", +] + +[[package]] +name = "arrow-ord" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8f82583eb4f8d84d4ee55fd1cb306720cddead7596edce95b50ee418edf66f" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", +] + +[[package]] +name = "arrow-row" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d07ba24522229d9085031df6b94605e0f4b26e099fb7cdeec37abd941a73753" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "half 2.4.1", +] + +[[package]] +name = "arrow-schema" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3aa9e59c611ebc291c28582077ef25c97f1975383f1479b12f3b9ffee2ffabe" + +[[package]] +name = "arrow-select" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c41dbbd1e97bfcaee4fcb30e29105fb2c75e4d82ae4de70b792a5d3f66b2e7a" +dependencies = [ + "ahash", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num", +] + +[[package]] +name = "arrow-string" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53f5183c150fbc619eede22b861ea7c0eebed8eaac0333eaa7f6da5205fd504d" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "memchr", + "num", + "regex", + "regex-syntax", +] + +[[package]] +name = "as-any" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f477b951e452a0b6b4a10b53ccd569042d1d01729b519e02074a9c0958a063" + [[package]] name = "asn1-rs" version = "0.5.2" @@ -128,7 +442,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror", + "thiserror 1.0.63", "time", ] @@ -155,6 +469,18 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -177,6 +503,12 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -188,6 +520,15 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -196,9 +537,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.3.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-config" @@ -385,7 +726,7 @@ dependencies = [ "hex", "hmac", "http 0.2.12", - "http 1.1.0", + "http 1.4.0", "once_cell", "percent-encoding", "sha2", @@ -455,17 +796,17 @@ dependencies = [ "aws-smithy-types", "bytes", "fastrand", - "h2", + "h2 0.3.26", "http 0.2.12", "http-body 0.4.6", "http-body 1.0.1", "httparse", "hyper 0.14.30", - "hyper-rustls", + "hyper-rustls 0.24.2", "once_cell", "pin-project-lite", "pin-utils", - "rustls", + "rustls 0.21.12", "tokio", "tracing", ] @@ -480,7 +821,7 @@ dependencies = [ "aws-smithy-types", "bytes", "http 0.2.12", - "http 1.1.0", + "http 1.4.0", "pin-project-lite", "tokio", "tracing", @@ -498,7 +839,7 @@ dependencies = [ "bytes-utils", "futures-core", "http 0.2.12", - "http 1.1.0", + "http 1.4.0", "http-body 0.4.6", "http-body 1.0.1", "http-body-util", @@ -547,7 +888,7 @@ dependencies = [ "axum-macros", "bytes", "futures-util", - "http 1.1.0", + "http 1.4.0", "http-body 1.0.1", "http-body-util", "hyper 1.7.0", @@ -581,7 +922,7 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http 1.1.0", + "http 1.4.0", "http-body 1.0.1", "http-body-util", "mime", @@ -612,10 +953,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" dependencies = [ "futures-core", - "getrandom", + "getrandom 0.2.15", "instant", "pin-project-lite", - "rand", + "rand 0.8.5", "tokio", ] @@ -629,19 +970,88 @@ dependencies = [ "cc", "cfg-if", "libc", - "miniz_oxide", + "miniz_oxide 0.7.4", "object", "rustc-demangle", ] +[[package]] +name = "baml-ids" +version = "0.0.1" +source = "git+https://github.com/OpenSecretCloud/DSRs.git?branch=main#be22bef0008bd3631ca4ebd2b873d47c0dbc5916" +dependencies = [ + "anyhow", + "getrandom 0.2.15", + "serde", + "time", + "type-safe-id", + "uuid", +] + +[[package]] +name = "baml-types" +version = "0.1.0" +source = "git+https://github.com/OpenSecretCloud/DSRs.git?branch=main#be22bef0008bd3631ca4ebd2b873d47c0dbc5916" +dependencies = [ + "anyhow", + "baml-ids", + "clap", + "indexmap", + "internal-baml-diagnostics", + "itertools", + "minijinja", + "pretty", + "secrecy", + "serde", + "serde_json", + "strum", + "web-time", +] + +[[package]] +name = "bamltype" +version = "0.1.0" +source = "git+https://github.com/OpenSecretCloud/DSRs.git?branch=main#be22bef0008bd3631ca4ebd2b873d47c0dbc5916" +dependencies = [ + "anyhow", + "baml-types", + "bamltype-derive", + "facet", + "facet-reflect", + "indexmap", + "internal-baml-jinja", + "jsonish", + "minijinja", + "sha2", + "thiserror 2.0.18", +] + +[[package]] +name = "bamltype-derive" +version = "0.1.0" +source = "git+https://github.com/OpenSecretCloud/DSRs.git?branch=main#be22bef0008bd3631ca4ebd2b873d47c0dbc5916" +dependencies = [ + "convert_case", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base58ck" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" dependencies = [ - "bitcoin-internals 0.3.0", - "bitcoin_hashes 0.14.0", + "bitcoin-internals", + "bitcoin_hashes", ] [[package]] @@ -684,7 +1094,7 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36915bbaca237c626689b5bd14d02f2ba7a5a359d30a2a08be697392e3718079" dependencies = [ - "thiserror", + "thiserror 1.0.63", ] [[package]] @@ -707,13 +1117,22 @@ dependencies = [ "serde", ] +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bip39" -version = "2.1.0" +version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33415e24172c1b7d6066f6d999545375ab8e1d95421d6784bdfff9496f292387" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ - "bitcoin_hashes 0.13.0", + "bitcoin_hashes", "serde", "unicode-normalization", ] @@ -754,22 +1173,16 @@ checksum = "ce6bc65742dea50536e35ad42492b234c27904a27f0abdcbce605015cb4ea026" dependencies = [ "base58ck", "bech32", - "bitcoin-internals 0.3.0", + "bitcoin-internals", "bitcoin-io", "bitcoin-units", - "bitcoin_hashes 0.14.0", - "hex-conservative 0.2.1", + "bitcoin_hashes", + "hex-conservative", "hex_lit", "secp256k1", "serde", ] -[[package]] -name = "bitcoin-internals" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb" - [[package]] name = "bitcoin-internals" version = "0.3.0" @@ -791,20 +1204,10 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2" dependencies = [ - "bitcoin-internals 0.3.0", + "bitcoin-internals", "serde", ] -[[package]] -name = "bitcoin_hashes" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b" -dependencies = [ - "bitcoin-internals 0.2.0", - "hex-conservative 0.1.2", -] - [[package]] name = "bitcoin_hashes" version = "0.14.0" @@ -812,7 +1215,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb18c03d0db0247e147a21a6faafd5a7eb851c743db062de72018b6b7e8e4d16" dependencies = [ "bitcoin-io", - "hex-conservative 0.2.1", + "hex-conservative", "serde", ] @@ -824,9 +1227,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.6.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "blake2" @@ -856,34 +1259,91 @@ dependencies = [ ] [[package]] -name = "bstr" -version = "1.12.0" +name = "bon" +version = "3.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +checksum = "234655ec178edd82b891e262ea7cf71f6584bcd09eff94db786be23f1821825c" dependencies = [ - "memchr", - "regex-automata 0.4.11", - "serde", + "bon-macros", + "rustversion", ] [[package]] -name = "bumpalo" -version = "3.16.0" +name = "bon-macros" +version = "3.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +checksum = "89ec27229c38ed0eb3c0feee3d2c1d6a4379ae44f418a29a658890e062d8f365" +dependencies = [ + "darling 0.20.10", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.106", +] [[package]] -name = "byteorder" -version = "1.5.0" +name = "brotli" +version = "8.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] [[package]] -name = "bytes" -version = "1.10.1" +name = "brotli-decompressor" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" - +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bstd" +version = "0.1.0" +source = "git+https://github.com/OpenSecretCloud/DSRs.git?branch=main#be22bef0008bd3631ca4ebd2b873d47c0dbc5916" +dependencies = [ + "anyhow", + "num", + "rand 0.8.5", + "regex", +] + +[[package]] +name = "bstr" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + [[package]] name = "bytes-utils" version = "0.1.4" @@ -908,6 +1368,10 @@ name = "cc" version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9e8aabfac534be767c909e0690571677d49f41bd8465ae876fe043d52ba5292" +dependencies = [ + "jobserver", + "libc", +] [[package]] name = "cfg-if" @@ -947,17 +1411,26 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.38" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", - "windows-targets 0.52.6", + "windows-link 0.2.1", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", ] [[package]] @@ -998,6 +1471,46 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clap" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim 0.11.1", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + [[package]] name = "cmac" version = "0.7.2" @@ -1009,6 +1522,91 @@ dependencies = [ "digest", ] +[[package]] +name = "cmsketch" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7ee2cfacbd29706479902b06d75ad8f1362900836aa32799eabc7e004bfd854" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + +[[package]] +name = "const-fnv1a-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b13ea120a812beba79e34316b3942a857c86ec1593cb34f27bb28272ce2cca" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.15", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1025,6 +1623,17 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_affinity" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a034b3a7b624016c6e13f5df875747cc25f884156aad2abd12b6c46797971342" +dependencies = [ + "libc", + "num_cpus", + "winapi", +] + [[package]] name = "cpufeatures" version = "0.2.13" @@ -1034,6 +1643,34 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.20" @@ -1046,6 +1683,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -1053,10 +1702,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "ctr" version = "0.9.2" @@ -1092,14 +1762,38 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core 0.14.4", + "darling_macro 0.14.4", +] + [[package]] name = "darling" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.10", + "darling_macro 0.20.10", +] + +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", ] [[package]] @@ -1112,17 +1806,28 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 2.0.106", ] +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core 0.14.4", + "quote", + "syn 1.0.109", +] + [[package]] name = "darling_macro" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ - "darling_core", + "darling_core 0.20.10", "quote", "syn 2.0.106", ] @@ -1155,6 +1860,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "der-parser" version = "8.2.0" @@ -1185,7 +1901,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf97ee7261bb708fa3402fa9c17a54b70e90e3cb98afb3dc8999d5512cb03f94" dependencies = [ "bigdecimal", - "bitflags 2.6.0", + "bitflags 2.10.0", "byteorder", "chrono", "diesel_derives", @@ -1240,10 +1956,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1261,13 +1999,19 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dsl_auto_type" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9abe6314103864cc2d8901b7ae224e0ab1a103a0a416661b4097b0779b607" dependencies = [ - "darling", + "darling 0.20.10", "either", "heck 0.5.0", "proc-macro2", @@ -1275,6 +2019,74 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "dspy-rs" +version = "0.7.3" +source = "git+https://github.com/OpenSecretCloud/DSRs.git?branch=main#be22bef0008bd3631ca4ebd2b873d47c0dbc5916" +dependencies = [ + "anyhow", + "arrow", + "async-trait", + "bamltype", + "bon", + "csv", + "dsrs_macros", + "enum_dispatch", + "facet", + "foyer", + "futures", + "hf-hub", + "indexmap", + "kdam", + "parquet", + "rand 0.8.5", + "rayon", + "regex", + "reqwest 0.12.23", + "rig-core", + "rstest", + "schemars", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "dsrs_macros" +version = "0.7.2" +source = "git+https://github.com/OpenSecretCloud/DSRs.git?branch=main#be22bef0008bd3631ca4ebd2b873d47c0dbc5916" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.106", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "ecow" version = "0.2.2" @@ -1290,29 +2102,197 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" +checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "eventsource-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" +dependencies = [ + "futures-core", + "nom", + "pin-project-lite", +] + +[[package]] +name = "facet" +version = "0.43.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e338357cf598728b41e45744d024bdc063338214992361766928a1421bd7541d" +dependencies = [ + "autocfg", + "facet-core", + "facet-macros", +] + +[[package]] +name = "facet-core" +version = "0.43.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a63e0ade4c53b40220614b8fc2a0a0ce21975941b553081521a195c848b2e9c2" +dependencies = [ + "autocfg", + "const-fnv1a-hash", + "iddqd", + "impls", +] + +[[package]] +name = "facet-macro-parse" +version = "0.43.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83ea29147986d0e184600cec533c41d6065c3c3d4b5b5745a8403494ca216b09" +dependencies = [ + "facet-macro-types", + "proc-macro2", + "quote", +] + +[[package]] +name = "facet-macro-types" +version = "0.43.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b0035cf41c0d4eeee82effc9161512d216d1378dd89c4d8721258429e38597" +dependencies = [ + "proc-macro2", + "quote", + "unsynn", +] + +[[package]] +name = "facet-macros" +version = "0.43.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a784f2fa36d3165b95639af790249dee0d8efdef7d53f9417cace91697e2e3" dependencies = [ - "cfg-if", + "facet-macros-impl", ] [[package]] -name = "equivalent" -version = "1.0.1" +name = "facet-macros-impl" +version = "0.43.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +checksum = "cf8f45c6380398bf74e59b97a20012de571502c609e580d84579d1140e491c1c" +dependencies = [ + "facet-macro-parse", + "facet-macro-types", + "proc-macro2", + "quote", + "unsynn", +] [[package]] -name = "errno" -version = "0.3.14" +name = "facet-reflect" +version = "0.43.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "4418c9fceaac9adcd055cc3732954d79b5d67ef04fb855dd219f2b314ba26cff" dependencies = [ - "libc", - "windows-sys 0.59.0", + "facet-core", ] [[package]] @@ -1325,24 +2305,89 @@ dependencies = [ "regex", ] +[[package]] +name = "fastant" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e825441bfb2d831c47c97d05821552db8832479f44c571b97fededbf0099c07" +dependencies = [ + "small_ctor", + "web-time", +] + [[package]] name = "fastrand" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" +dependencies = [ + "bitflags 2.10.0", + "rustc_version", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide 0.8.9", + "zlib-rs", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "nanorand", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1367,6 +2412,123 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "foyer" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7b23bdff0e7fecbef83438ce9389c352743ea0b8bda44fabca354f21b19a9" +dependencies = [ + "equivalent", + "foyer-common", + "foyer-memory", + "foyer-storage", + "madsim-tokio", + "mixtrics", + "pin-project", + "serde", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "foyer-common" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a72f40b0c110e4233f46df0589bf1b2b3b5ed04e4cf504a582374d7548c9c05" +dependencies = [ + "bincode", + "bytes", + "cfg-if", + "itertools", + "madsim-tokio", + "mixtrics", + "parking_lot", + "pin-project", + "serde", + "thiserror 2.0.18", + "tokio", + "twox-hash", +] + +[[package]] +name = "foyer-intrusive-collections" +version = "0.10.0-dev" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4fee46bea69e0596130e3210e65d3424e0ac1e6df3bde6636304bdf1ca4a3b" +dependencies = [ + "memoffset 0.9.1", +] + +[[package]] +name = "foyer-memory" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1b23cefdcd78b4d729679d75bb6fb96a57f7dd94f141dcc373a2db1c04fbaa" +dependencies = [ + "arc-swap", + "bitflags 2.10.0", + "cmsketch", + "equivalent", + "foyer-common", + "foyer-intrusive-collections", + "hashbrown 0.15.5", + "itertools", + "madsim-tokio", + "mixtrics", + "parking_lot", + "pin-project", + "serde", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "foyer-storage" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1ea5872e0073e3c37bec0aa99923f03bcec3951a056c77cfdfcc8001a370f9a" +dependencies = [ + "allocator-api2", + "anyhow", + "bytes", + "core_affinity", + "equivalent", + "fastant", + "flume", + "foyer-common", + "foyer-memory", + "fs4", + "futures-core", + "futures-util", + "hashbrown 0.15.5", + "io-uring", + "itertools", + "libc", + "lz4", + "madsim-tokio", + "parking_lot", + "pin-project", + "rand 0.9.2", + "serde", + "thiserror 2.0.18", + "tokio", + "tracing", + "twox-hash", + "zstd", +] + +[[package]] +name = "fs4" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" +dependencies = [ + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "futures" version = "0.3.31" @@ -1470,6 +2632,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1485,6 +2648,31 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "ghash" version = "0.5.1" @@ -1501,6 +2689,12 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "governor" version = "0.6.3" @@ -1516,11 +2710,22 @@ dependencies = [ "parking_lot", "portable-atomic", "quanta", - "rand", + "rand 0.8.5", "smallvec", "spinning_top", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.3.26" @@ -1540,6 +2745,25 @@ dependencies = [ "tracing", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "1.8.3" @@ -1554,6 +2778,7 @@ checksum = "6dd08c532ae367adf81c312a4580bc67f1d0fe8bc9c460520283f4c0ff277888" dependencies = [ "cfg-if", "crunchy", + "num-traits", ] [[package]] @@ -1562,11 +2787,25 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +dependencies = [ + "allocator-api2", +] [[package]] name = "heck" @@ -1587,16 +2826,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" [[package]] -name = "hex" -version = "0.4.3" +name = "hermit-abi" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] -name = "hex-conservative" -version = "0.1.2" +name = "hex" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hex-conservative" @@ -1604,7 +2843,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5313b072ce3c597065a808dbf612c4c8e8590bdbf8b579508bf7a762c5eae6cd" dependencies = [ - "arrayvec", + "arrayvec 0.7.6", ] [[package]] @@ -1613,6 +2852,39 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" +[[package]] +name = "hf-hub" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" +dependencies = [ + "dirs", + "futures", + "http 1.4.0", + "indicatif", + "libc", + "log", + "native-tls", + "num_cpus", + "rand 0.9.2", + "reqwest 0.12.23", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "ureq", + "windows-sys 0.60.2", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1635,12 +2907,11 @@ dependencies = [ [[package]] name = "http" -version = "1.1.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -1662,7 +2933,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.1.0", + "http 1.4.0", ] [[package]] @@ -1673,7 +2944,7 @@ checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" dependencies = [ "bytes", "futures-util", - "http 1.1.0", + "http 1.4.0", "http-body 1.0.1", "pin-project-lite", ] @@ -1700,7 +2971,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2", + "h2 0.3.26", "http 0.2.12", "http-body 0.4.6", "httparse", @@ -1724,7 +2995,8 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "http 1.1.0", + "h2 0.4.13", + "http 1.4.0", "http-body 1.0.1", "httparse", "httpdate", @@ -1746,10 +3018,26 @@ dependencies = [ "http 0.2.12", "hyper 0.14.30", "log", - "rustls", + "rustls 0.21.12", "rustls-native-certs", "tokio", - "tokio-rustls", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.4.0", + "hyper 1.7.0", + "hyper-util", + "rustls 0.23.14", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.0", + "tower-service", ] [[package]] @@ -1792,7 +3080,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http 1.1.0", + "http 1.4.0", "http-body 1.0.1", "hyper 1.7.0", "ipnet", @@ -1800,9 +3088,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.0", + "system-configuration 0.6.1", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1946,6 +3236,25 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "iddqd" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b215e67ed1d1a4b1702acd787c487d16e4c977c5dcbcc4587bdb5ea26b6ce06" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", + "hashbrown 0.16.0", + "rustc-hash 2.1.1", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -1983,6 +3292,12 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "impls" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a46645bbd70538861a90d0f26c31537cdf1e44aae99a794fb75a664b70951bc" + [[package]] name = "indexmap" version = "2.11.4" @@ -1991,6 +3306,21 @@ checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", "hashbrown 0.16.0", + "serde", + "serde_core", +] + +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", ] [[package]] @@ -2009,7 +3339,40 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" dependencies = [ - "cfg-if", + "cfg-if", +] + +[[package]] +name = "integer-encoding" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" + +[[package]] +name = "internal-baml-diagnostics" +version = "0.1.0" +source = "git+https://github.com/OpenSecretCloud/DSRs.git?branch=main#be22bef0008bd3631ca4ebd2b873d47c0dbc5916" +dependencies = [ + "anyhow", + "colored", + "pest", + "serde", + "strsim 0.11.1", +] + +[[package]] +name = "internal-baml-jinja" +version = "0.1.0" +source = "git+https://github.com/OpenSecretCloud/DSRs.git?branch=main#be22bef0008bd3631ca4ebd2b873d47c0dbc5916" +dependencies = [ + "anyhow", + "baml-types", + "indexmap", + "minijinja", + "serde_json", + "serde_yaml", + "strum", + "toon", ] [[package]] @@ -2018,7 +3381,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.10.0", "cfg-if", "libc", ] @@ -2039,12 +3402,37 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.81" @@ -2055,6 +3443,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonish" +version = "0.1.0" +source = "git+https://github.com/OpenSecretCloud/DSRs.git?branch=main#be22bef0008bd3631ca4ebd2b873d47c0dbc5916" +dependencies = [ + "anyhow", + "baml-types", + "bstd", + "getrandom 0.2.15", + "indexmap", + "internal-baml-jinja", + "log", + "minijinja", + "regex", + "serde", + "serde_json", + "strsim 0.11.1", + "test-log", + "thiserror 2.0.18", + "unicode-normalization", + "uuid", +] + [[package]] name = "jsonwebtoken" version = "9.3.0" @@ -2082,7 +3493,7 @@ dependencies = [ "ciborium", "hmac", "lazy_static", - "rand_core", + "rand_core 0.6.4", "secp256k1", "serde", "serde_json", @@ -2092,6 +3503,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "kdam" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d847be338ef16a13f97637c062d97fb52ebe0ff3b77fa18456d5ed366317e4f7" +dependencies = [ + "terminal_size", + "windows-sys 0.61.2", +] + [[package]] name = "keccak" version = "0.1.5" @@ -2107,6 +3528,69 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + [[package]] name = "libc" version = "0.2.177" @@ -2119,11 +3603,21 @@ version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.10.0", + "libc", +] + [[package]] name = "linux-raw-sys" -version = "0.4.14" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" @@ -2147,13 +3641,96 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "lz4" +version = "1.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4" +dependencies = [ + "lz4-sys", +] + +[[package]] +name = "lz4-sys" +version = "1.11.1+lz4-1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "lz4_flex" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "madsim" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18351aac4194337d6ea9ffbd25b3d1540ecc0754142af1bff5ba7392d1f6f771" +dependencies = [ + "ahash", + "async-channel", + "async-stream", + "async-task", + "bincode", + "bytes", + "downcast-rs", + "errno", + "futures-util", + "lazy_static", + "libc", + "madsim-macros", + "naive-timer", + "panic-message", + "rand 0.8.5", + "rand_xoshiro", + "rustversion", + "serde", + "spin", + "tokio", + "tokio-util", + "toml", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "madsim-macros" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3d248e97b1a48826a12c3828d921e8548e714394bf17274dd0a93910dc946e1" +dependencies = [ + "darling 0.14.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "madsim-tokio" +version = "0.2.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d3eb2acc57c82d21d699119b859e2df70a91dbdb84734885a1e72be83bdecb5" +dependencies = [ + "madsim", + "spin", + "tokio", +] + [[package]] name = "matchers" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "regex-automata 0.1.10", + "regex-automata", ] [[package]] @@ -2203,6 +3780,29 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minijinja" +version = "2.11.0" +source = "git+https://github.com/boundaryml/minijinja.git?branch=main#ee25c021c8bb78d7ca11fa99972918eea4585330" +dependencies = [ + "aho-corasick", + "indexmap", + "serde", + "serde_json", + "unicase", + "unicode-ident", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2218,18 +3818,38 @@ dependencies = [ "adler", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ - "hermit-abi", + "hermit-abi 0.3.9", "libc", "wasi", "windows-sys 0.52.0", ] +[[package]] +name = "mixtrics" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb252c728b9d77c6ef9103f0c81524fa0a3d3b161d0a936295d7fbeff6e04c11" +dependencies = [ + "itertools", + "parking_lot", +] + [[package]] name = "multer" version = "3.1.0" @@ -2239,7 +3859,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http 1.1.0", + "http 1.4.0", "httparse", "memchr", "mime", @@ -2247,6 +3867,27 @@ dependencies = [ "version_check", ] +[[package]] +name = "mutants" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0287524726960e07b119cebd01678f852f147742ae0d925e6a520dca956126" + +[[package]] +name = "naive-timer" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "034a0ad7deebf0c2abcf2435950a6666c3c15ea9d8fad0c0f48efa8a7f843fed" + +[[package]] +name = "nanorand" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" +dependencies = [ + "getrandom 0.2.15", +] + [[package]] name = "native-tls" version = "0.2.12" @@ -2283,7 +3924,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.10.0", "cfg-if", "cfg_aliases", "libc", @@ -2314,12 +3955,25 @@ checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" [[package]] name = "nu-ansi-term" -version = "0.46.0" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "overload", - "winapi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", ] [[package]] @@ -2332,6 +3986,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2347,6 +4010,28 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2354,8 +4039,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi 0.5.2", + "libc", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "oauth2" version = "4.4.2" @@ -2364,15 +4066,15 @@ checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f" dependencies = [ "base64 0.13.1", "chrono", - "getrandom", + "getrandom 0.2.15", "http 0.2.12", - "rand", + "rand 0.8.5", "reqwest 0.11.27", "serde", "serde_json", "serde_path_to_error", "sha2", - "thiserror", + "thiserror 1.0.63", "url", ] @@ -2400,6 +4102,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -2431,13 +4139,16 @@ dependencies = [ "cbc", "chacha20poly1305", "chrono", + "chrono-tz", "diesel", "diesel-derive-enum", "dotenv", + "dspy-rs", "futures", "generic-array", - "getrandom", + "getrandom 0.2.15", "hex", + "hkdf", "hmac", "hyper 0.14.30", "hyper-tls 0.5.0", @@ -2446,8 +4157,9 @@ dependencies = [ "lazy_static", "oauth2", "once_cell", + "p256", "password-auth", - "rand_core", + "rand_core 0.6.4", "rcgen", "regex", "reqwest 0.11.27", @@ -2459,13 +4171,14 @@ dependencies = [ "serde_json", "sha2", "subtle", - "thiserror", + "thiserror 1.0.63", "tiktoken-rs", "tokio", "tokio-stream", "tower-http 0.5.2", "tracing", "tracing-subscriber", + "unicode-segmentation", "url", "uuid", "validator", @@ -2481,7 +4194,7 @@ version = "0.10.66" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9529f4786b70a3e8c61e11179af17ab6188ad8d0ded78c5529441ed39d4bd9c1" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.10.0", "cfg-if", "foreign-types", "libc", @@ -2519,6 +4232,30 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-float" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" +dependencies = [ + "num-traits", +] + [[package]] name = "outref" version = "0.5.1" @@ -2526,10 +4263,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4030760ffd992bef45b0ae3f10ce1aba99e33464c90d14dd7c039884963ddc7a" [[package]] -name = "overload" -version = "0.1.1" +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "panic-message" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384e52fd8fbd4cbe3c317e8216260c21a0f9134de108cea8a4dd4e7e152c472d" + +[[package]] +name = "parking" +version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" @@ -2554,6 +4309,39 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "parquet" +version = "56.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dbd48ad52d7dccf8ea1b90a3ddbfaea4f69878dd7683e51c507d4bc52b5b27" +dependencies = [ + "ahash", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-ipc", + "arrow-schema", + "arrow-select", + "base64 0.22.1", + "brotli", + "bytes", + "chrono", + "flate2", + "half 2.4.1", + "hashbrown 0.16.0", + "lz4_flex", + "num", + "num-bigint", + "paste", + "seq-macro", + "simdutf8", + "snap", + "thrift", + "twox-hash", + "zstd", +] + [[package]] name = "password-auth" version = "1.0.0" @@ -2561,9 +4349,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a2a4764cc1f8d961d802af27193c6f4f0124bd0e76e8393cf818e18880f0524" dependencies = [ "argon2", - "getrandom", + "getrandom 0.2.15", "password-hash", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2573,10 +4361,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", - "rand_core", + "rand_core 0.6.4", "subtle", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pem" version = "3.0.4" @@ -2587,12 +4381,49 @@ dependencies = [ "serde", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.5" @@ -2615,9 +4446,9 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.14" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] name = "pin-utils" @@ -2625,6 +4456,16 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.30" @@ -2672,7 +4513,7 @@ version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" dependencies = [ - "zerocopy", + "zerocopy 0.7.35", ] [[package]] @@ -2684,6 +4525,45 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "pretty" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d22152487193190344590e4f30e219cf3fe140d9e7a3fdb683d82aa2c5f4156" +dependencies = [ + "arrayvec 0.5.2", + "typed-arena", + "unicode-width", +] + +[[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.106", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -2739,6 +4619,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r2d2" version = "0.8.10" @@ -2757,8 +4643,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -2768,7 +4664,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -2777,7 +4683,25 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.15", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xoshiro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" +dependencies = [ + "rand_core 0.6.4", ] [[package]] @@ -2786,7 +4710,27 @@ version = "11.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb9ee317cfe3fbd54b36a511efc1edd42e216903c9cd575e686dd68a2ba90d8d" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.10.0", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", ] [[package]] @@ -2808,28 +4752,50 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.10.0", ] [[package]] -name = "regex" -version = "1.11.3" +name = "redox_users" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "aho-corasick", - "memchr", - "regex-automata 0.4.11", - "regex-syntax 0.8.6", + "getrandom 0.2.15", + "libredox", + "thiserror 2.0.18", ] [[package]] -name = "regex-automata" -version = "0.1.10" +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.106", +] + +[[package]] +name = "regex" +version = "1.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" dependencies = [ - "regex-syntax 0.6.29", + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", ] [[package]] @@ -2840,7 +4806,7 @@ checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.6", + "regex-syntax", ] [[package]] @@ -2851,15 +4817,15 @@ checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" [[package]] name = "regex-syntax" -version = "0.6.29" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" [[package]] -name = "regex-syntax" -version = "0.8.6" +name = "relative-path" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" [[package]] name = "reqwest" @@ -2872,11 +4838,11 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2", + "h2 0.3.26", "http 0.2.12", "http-body 0.4.6", "hyper 0.14.30", - "hyper-rustls", + "hyper-rustls 0.24.2", "hyper-tls 0.5.0", "ipnet", "js-sys", @@ -2886,22 +4852,22 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls", + "rustls 0.21.12", "rustls-pemfile", "serde", "serde_json", "serde_urlencoded", "sync_wrapper 0.1.2", - "system-configuration", + "system-configuration 0.5.1", "tokio", "tokio-native-tls", - "tokio-rustls", + "tokio-rustls 0.24.1", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 0.25.4", "winreg", ] @@ -2913,15 +4879,22 @@ checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", + "futures-channel", "futures-core", - "http 1.1.0", + "futures-util", + "h2 0.4.13", + "http 1.4.0", "http-body 1.0.1", "http-body-util", "hyper 1.7.0", + "hyper-rustls 0.27.7", "hyper-tls 0.6.0", "hyper-util", "js-sys", "log", + "mime", + "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", @@ -2932,12 +4905,14 @@ dependencies = [ "sync_wrapper 1.0.1", "tokio", "tokio-native-tls", + "tokio-util", "tower 0.5.2", "tower-http 0.6.6", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", ] @@ -2950,10 +4925,48 @@ dependencies = [ "ecow", "governor", "maybe-async", - "rand", + "rand 0.8.5", + "reqwest 0.12.23", + "serde", + "thiserror 1.0.63", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rig-core" +version = "0.26.0" +source = "git+https://github.com/0xPlaygrounds/rig?rev=e7849df#e7849df20bce7c4eb3eb5ada87aae18435f9afa1" +dependencies = [ + "as-any", + "async-stream", + "base64 0.22.1", + "bytes", + "eventsource-stream", + "futures", + "futures-timer", + "glob", + "http 1.4.0", + "mime_guess", + "ordered-float 5.1.0", + "pin-project-lite", "reqwest 0.12.23", + "schemars", "serde", - "thiserror", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-futures", + "url", ] [[package]] @@ -2964,13 +4977,43 @@ checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.15", "libc", "spin", "untrusted", "windows-sys 0.52.0", ] +[[package]] +name = "rstest" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fc39292f8613e913f7df8fa892b8944ceb47c247b78e1b1ae2f09e019be789d" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f168d99749d307be9de54d23fd226628d99768225ef08f6ffb52e0182a27746" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.106", + "unicode-ident", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -2983,6 +5026,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3003,15 +5052,15 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.34" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3022,10 +5071,25 @@ checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" dependencies = [ "log", "ring", - "rustls-webpki", + "rustls-webpki 0.101.7", "sct", ] +[[package]] +name = "rustls" +version = "0.23.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "415d9944693cb90382053259f89fbb077ea730ad7273047ec63b19bc9b160ba8" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + [[package]] name = "rustls-native-certs" version = "0.6.3" @@ -3063,6 +5127,17 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3093,6 +5168,31 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.106", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3109,14 +5209,28 @@ dependencies = [ "untrusted", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "secp256k1" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e0cc0f1cf93f4969faf3ea1c7d8a9faed25918d96affa959720823dfe86d4f3" dependencies = [ - "bitcoin_hashes 0.14.0", - "rand", + "bitcoin_hashes", + "rand 0.8.5", "secp256k1-sys", "serde", ] @@ -3130,13 +5244,23 @@ dependencies = [ "cc", ] +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "serde", + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.10.0", "core-foundation", "core-foundation-sys", "libc", @@ -3159,6 +5283,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + [[package]] name = "serde" version = "1.0.228" @@ -3198,10 +5328,21 @@ dependencies = [ ] [[package]] -name = "serde_derive" -version = "1.0.228" +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.106", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", @@ -3214,6 +5355,7 @@ version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ + "indexmap", "itoa", "memchr", "ryu", @@ -3231,6 +5373,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3243,11 +5394,24 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha2" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", @@ -3282,6 +5446,28 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simple_asn1" version = "0.6.2" @@ -3290,10 +5476,16 @@ checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" dependencies = [ "num-bigint", "num-traits", - "thiserror", + "thiserror 1.0.63", "time", ] +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "slab" version = "0.4.9" @@ -3303,12 +5495,24 @@ dependencies = [ "autocfg", ] +[[package]] +name = "small_ctor" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88414a5ca1f85d82cc34471e975f0f74f6aa54c40f062efa42c0080e7f763f81" + [[package]] name = "smallvec" version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +[[package]] +name = "snap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" + [[package]] name = "socket2" version = "0.5.7" @@ -3329,11 +5533,25 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + [[package]] name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] [[package]] name = "spinning_top" @@ -3344,18 +5562,56 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.106", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3430,7 +5686,18 @@ checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", "core-foundation", - "system-configuration-sys", + "system-configuration-sys 0.5.0", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.10.0", + "core-foundation", + "system-configuration-sys 0.6.0", ] [[package]] @@ -3443,17 +5710,59 @@ dependencies = [ "libc", ] +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tempfile" -version = "3.12.0" +version = "3.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ - "cfg-if", "fastrand", + "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +dependencies = [ + "rustix", + "windows-sys 0.60.2", +] + +[[package]] +name = "test-log" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37d53ac171c92a39e4769491c4b4dde7022c60042254b5fc044ae409d34a24d4" +dependencies = [ + "env_logger", + "test-log-macros", + "tracing-subscriber", +] + +[[package]] +name = "test-log-macros" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be35209fd0781c5401458ab66e4f98accf63553e8fae7425503e92fdd319783b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] @@ -3462,7 +5771,16 @@ version = "1.0.63" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.63", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -3476,6 +5794,17 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "thread_local" version = "1.1.8" @@ -3486,6 +5815,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "thrift" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e54bc85fc7faa8bc175c4bab5b92ba8d9a3ce893d0e9f42cc455c8ab16a9e09" +dependencies = [ + "byteorder", + "integer-encoding", + "ordered-float 2.10.1", +] + [[package]] name = "tiktoken-rs" version = "0.5.9" @@ -3498,7 +5838,7 @@ dependencies = [ "fancy-regex", "lazy_static", "parking_lot", - "rustc-hash", + "rustc-hash 1.1.0", ] [[package]] @@ -3532,6 +5872,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.7.6" @@ -3604,7 +5953,18 @@ version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" dependencies = [ - "rustls", + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" +dependencies = [ + "rustls 0.23.14", + "rustls-pki-types", "tokio", ] @@ -3632,6 +5992,67 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.7+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "247eaa3197818b831697600aadf81514e577e0cba5eab10f7e064e78ae154df1" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "toon" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa605a2aa68fefdedc9560bd765ce3ad89d45ac1d3ebad43724621098c9299a" +dependencies = [ + "regex", + "serde_json", +] + [[package]] name = "tower" version = "0.4.13" @@ -3669,9 +6090,9 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.10.0", "bytes", - "http 1.1.0", + "http 1.4.0", "http-body 1.0.1", "http-body-util", "pin-project-lite", @@ -3685,10 +6106,10 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.10.0", "bytes", "futures-util", - "http 1.1.0", + "http 1.4.0", "http-body 1.0.1", "iri-string", "pin-project-lite", @@ -3711,9 +6132,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -3723,9 +6144,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -3734,14 +6155,26 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", ] +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "futures", + "futures-task", + "pin-project", + "tracing", +] + [[package]] name = "tracing-log" version = "0.2.0" @@ -3755,14 +6188,14 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.18" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", "nu-ansi-term", "once_cell", - "regex", + "regex-automata", "sharded-slab", "smallvec", "thread_local", @@ -3777,12 +6210,51 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" +dependencies = [ + "rand 0.9.2", +] + +[[package]] +name = "type-safe-id" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa0f2333ec5a49706b6f29896d0e4cdba1665d2bf2d752d0c4f130634935d42f" +dependencies = [ + "arrayvec 0.7.6", + "rand 0.8.5", + "thiserror 2.0.18", + "uuid", +] + +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + [[package]] name = "typenum" version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.15" @@ -3797,27 +6269,56 @@ checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] name = "unicode-normalization" -version = "0.1.22" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" [[package]] -name = "universal-hash" -version = "0.5.1" +name = "unsynn" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +checksum = "501a7adf1a4bd9951501e5c66621e972ef8874d787628b7f90e64f936ef7ec0a" dependencies = [ - "crypto-common", - "subtle", + "mutants", + "proc-macro2", + "rustc-hash 2.1.1", ] [[package]] @@ -3826,6 +6327,26 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74fc6b57825be3373f7054754755f03ac3a8f5d70015ccad699ba2029956f4a" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "native-tls", + "once_cell", + "rustls 0.23.14", + "rustls-pki-types", + "serde", + "serde_json", + "socks", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.2" @@ -3856,14 +6377,21 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" dependencies = [ - "getrandom", + "getrandom 0.2.15", "serde", + "wasm-bindgen", ] [[package]] @@ -3888,7 +6416,7 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" dependencies = [ - "darling", + "darling 0.20.10", "once_cell", "proc-macro-error2", "proc-macro2", @@ -3945,6 +6473,24 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[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", +] + [[package]] name = "wasm-bindgen" version = "0.2.104" @@ -4017,6 +6563,53 @@ 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 = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.81" @@ -4027,12 +6620,40 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.25.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -4064,6 +6685,47 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -4091,6 +6753,24 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -4115,13 +6795,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -4134,6 +6831,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -4146,6 +6849,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -4158,12 +6867,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -4176,6 +6897,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -4188,6 +6915,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -4200,6 +6933,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -4212,6 +6951,21 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.50.0" @@ -4222,6 +6976,94 @@ dependencies = [ "windows-sys 0.48.0", ] +[[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-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "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 0.5.0", + "indexmap", + "prettyplease", + "syn 2.0.106", + "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.106", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "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 = "write16" version = "1.0.0" @@ -4241,7 +7083,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ "curve25519-dalek", - "rand_core", + "rand_core 0.6.4", "serde", "zeroize", ] @@ -4259,7 +7101,7 @@ dependencies = [ "nom", "oid-registry", "rusticata-macros", - "thiserror", + "thiserror 1.0.63", "time", ] @@ -4309,7 +7151,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" dependencies = [ "byteorder", - "zerocopy-derive", + "zerocopy-derive 0.7.35", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive 0.8.39", ] [[package]] @@ -4323,6 +7174,17 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "zerofrom" version = "0.1.5" @@ -4385,3 +7247,37 @@ dependencies = [ "quote", "syn 2.0.106", ] + +[[package]] +name = "zlib-rs" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7948af682ccbc3342b6e9420e8c51c1fe5d7bf7756002b4a3c6cabfe96a7e3c" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 714f347a..6725556a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ thiserror = "1.0.63" async-trait = "0.1.81" jsonwebtoken = "9.3.0" jwt-compact = { version = "0.9.0-beta.1", features = ["es256k"] } +dspy-rs = { git = "https://github.com/OpenSecretCloud/DSRs.git", branch = "main" } diesel = { version = "=2.2.2", features = [ "postgres", "postgres_backend", @@ -34,6 +35,7 @@ diesel = { version = "=2.2.2", features = [ ] } diesel-derive-enum = { version = "2.1.0", features = ["postgres"] } chrono = { version = "0.4.26", features = ["serde"] } +chrono-tz = "0.10" dotenv = "0.15.0" aes-gcm = "0.10.1" aes-siv = "0.7" @@ -43,13 +45,15 @@ cbc = "0.1.2" secp256k1 = { version = "0.29.0", features = ["rand"] } hyper = { version = "0.14", features = ["full"] } hyper-tls = "0.5.0" -reqwest = { version = "0.11", features = ["json"] } +reqwest = { version = "0.11", features = ["json", "native-tls-alpn"] } futures = "0.3.30" uuid = { version = "1.10.0", features = ["v4", "serde"] } tokio-stream = "0.1" async-stream = "0.3" bytes = "1.0" sha2 = { version = "0.10", default-features = false } +p256 = { version = "0.13", features = ["ecdh", "pkcs8"] } +hkdf = "0.12" hex = "0.4.3" base64 = "0.22.1" vsock = "0.5.1" @@ -75,3 +79,4 @@ lazy_static = "1.4.0" subtle = "2.6.1" tiktoken-rs = "0.5" once_cell = "1.19" +unicode-segmentation = "1.12" diff --git a/docs/encrypted-mobile-push-notifications.md b/docs/encrypted-mobile-push-notifications.md new file mode 100644 index 00000000..b4b97110 --- /dev/null +++ b/docs/encrypted-mobile-push-notifications.md @@ -0,0 +1,1391 @@ +# Encrypted Mobile Push Notifications for Maple / OpenSecret + +## Backend, Enclave, and Mobile Design Reference + +**Date:** March 2026 +**Status:** Backend v1 is implemented for the Maple agent disconnect flow; Maple iOS encrypted preview and Android release delivery are implemented, macOS delivery works with generic fallback while encrypted-preview parity remains follow-up work, and broader event sources plus some client hardening remain open +**Related Docs:** +- `maple-agent-memory-architecture.md` +- `architecture-for-rag-integration.md` +- `potential-rag-integration-brute-force.md` + +--- + +## 1. Executive Summary + +This document defines the concrete v1 design for encrypted mobile push notifications in OpenSecret / Maple. + +The final architectural decisions are: + +1. **Use direct APNs for iOS and direct FCM HTTP v1 for Android.** +2. **Run the push sender inside the OpenSecret enclave app process.** +3. **Store provider configuration per project using `project_settings` + `org_project_secrets`.** +4. **Register one notification keypair per device, not one shared key per user.** +5. **Use a durable Postgres outbox + delivery worker, not synchronous request-path sends.** +6. **Treat successful live Maple SSE delivery as the acknowledgement for agent chat flows and skip push entirely when streaming succeeds.** +7. **Prefer standard visible-notification behavior on Android in v1; keep Android data-only encrypted preview as an optional later enhancement.** + +This gives us Signal-like transport privacy for push content without pretending we are building a full Signal protocol. + +### 1.1 Current backend scope in this repo + +As of this revision, the backend implementation in this repo currently includes: + +- project-scoped push settings and secrets +- device register / list / revoke routes plus logout cleanup fallback +- encrypted token storage, durable notification events, and per-device delivery rows +- direct APNs + direct FCM sender implementations +- background delivery worker with Postgres leasing +- successful Maple agent SSE delivery suppression plus disconnect-triggered `agent.message` enqueue + +The currently shipped backend scope is narrower than the full product design described later in this document: + +- the implemented event source in this repo is currently the Maple agent SSE disconnect path in `src/web/agent/mod.rs` +- reminder / task-complete / account-alert sources remain follow-up work +- iOS NSE behavior, Android client behavior, recent-ID caches, and thread cleanup live in mobile codebases rather than this backend repo + +### 1.2 Current Maple client status + +- **iOS release / TestFlight**: APNs registration, shared-keychain private-key access, notification toggle / revoke handling, and Notification Service Extension decryption are implemented. +- **macOS release / TestFlight**: APNs registration and delivery are implemented with a macOS notification service target, but the currently observed user-facing behavior still falls back to the generic notification text, so encrypted-preview parity remains follow-up work. +- **Android release**: FCM HTTP v1 registration / revoke flows, permission handling, token rotation, foreground refresh behavior, and generic visible notifications are implemented for the real release package `ai.trymaple.assistant`. +- **Android debug (`.dev`)**: not the intended end-to-end push validation lane. + +--- + +## 2. What Problem This Solves + +Longer-term Maple product goals include notifications for events like: + +- reminder due +- long-running agent task complete +- async follow-up from Maple +- security or account alerts + +Current backend implementation note: + +- this repo currently enqueues only `agent.message` notifications for interrupted Maple agent SSE turns + +while ensuring that: + +- Apple and Google do not need plaintext notification content +- minimal routing metadata may remain provider-visible in APNs / FCM payloads, and that is acceptable for v1 +- raw push tokens are not stored plaintext in Postgres +- losing one device does not compromise every other device +- the design fits the current OpenSecret enclave + project-scoped secret model + +--- + +## 3. Non-Goals + +This design does **not** attempt to provide: + +- a double-ratchet messaging protocol +- server blindness to the notification plaintext at creation time +- secrecy for all push metadata, timing information, routing identifiers, or provider-visible fields +- guaranteed hidden lock-screen content after the device itself decrypts it +- a single cross-device shared notification private key + +This is **encrypted push delivery**, not a general secure messaging redesign. + +--- + +## 4. Important Clarifications + +### 4.1 Apple and Google do not run our decryption logic + +They only deliver payloads. The only code we control at receipt time runs: + +- in an iOS **Notification Service Extension** +- in an Android **FirebaseMessagingService** / `WorkManager` flow + +So decryption must happen **on the device**. + +### 4.2 There is no special vsock registration with Apple or Google + +We do **not** register AWS vsock or Nitro plumbing with APNs or FCM. + +We only need: + +- outbound HTTPS access from the enclave to APNs / FCM endpoints +- parent-instance `vsock-proxy` allowlist entries +- enclave-local `/etc/hosts` entries plus `traffic_forwarder.py` mappings + +### 4.3 Android and iOS do not behave the same + +iOS supports the strongest version of the “messaging app” pattern: + +- send a generic visible alert +- include encrypted preview payload +- let NSE replace the visible content before display +- if the NSE fails or times out, iOS shows the original generic alert +- if the app is already foregrounded, the app can suppress the visible notification via `willPresent` + +Android does **not** offer the same “generic visible fallback + encrypted replacement” behavior for encrypted previews. The standard Android messaging-app approach is usually: + +- send a standard visible FCM notification with non-sensitive text +- include extra `data` fields for routing / grouping / dedup +- if the app is already foregrounded, handle it in-app and avoid showing another local notification + +Android **can** do encrypted preview with **high-priority data-only** FCM, but that is less standard and depends more heavily on background execution. + +So the v1 system should explicitly model Android delivery as: + +1. **Standard visible notification**: default v1 path, most reliable, non-sensitive visible text, app-open fetch for authoritative content +2. **Encrypted preview best-effort**: optional later path, richer UX, but depends on app background execution + +This is the most important platform caveat in the whole design. We should follow the standard platform patterns rather than force parity where the OSes behave differently. + +--- + +## 5. Final Architectural Decisions + +### 5.1 Per-device notification keypairs + +Each device generates its own notification keypair locally and registers only the public key. + +Why: + +- push routing is already device-specific +- revocation is per-device +- one compromised device does not force full-account rotation +- mobile secure storage works best with locally generated keys + +### 5.2 Separate push crypto from existing user-key crypto + +Push crypto must **not** reuse the current server-derived secp256k1 user-key flow. + +Use a dedicated **P-256 ECDH** notification keypair instead. + +Why: + +- better native support on iOS and Android +- easier hardware-backed storage +- avoids importing a shared account root into device secure storage + +### 5.3 Push sender runs inside the enclave app + +The sender should live inside the main OpenSecret server process, behind the current enclave boundary. + +Why: + +- push payload plaintext can be sensitive +- APNs / FCM credentials are sensitive +- push tokens are sensitive enough to encrypt at rest +- this keeps the privacy story aligned with the rest of OpenSecret + +### 5.4 Successful live Maple SSE delivery suppresses push in v1 + +For the common “user sent a message to Maple and is waiting on an SSE stream” flow: + +- if the final Maple response is delivered successfully over the open SSE stream, do **not** enqueue push for any device +- if the SSE stream closes or errors before the final response is delivered, enqueue **exactly one** notification event for that interrupted turn +- if the interrupted turn produced multiple assistant messages, use the **first missed assistant message** as the preview source for that one notification +- this SSE success signal acts as the acknowledgement; no extra per-thread presence service or explicit ACK protocol is required in v1 + +This is intentionally simple and matches the normal messaging-app intuition: if the user is clearly active on one device and the live response succeeded, don’t notify every other device. + +### 5.5 Outbox + worker, not inline send + +Notification generation and transport delivery must be separate steps. + +Why: + +- APNs / FCM reject, throttle, and invalidate tokens +- we need retries and backoff +- future reminder scheduling requires durable queue semantics +- request handlers should not block on provider APIs + +### 5.6 Stable notification IDs + client dedup are required + +Every logical notification must have a stable `notification_id`. + +Use `notification_events.uuid` as that canonical ID. + +Rules: + +- retries must reuse the same `notification_id` +- clients should keep a small cache of recently seen `notification_id` values and no-op duplicates +- APNs `apns-collapse-id` and FCM `collapse_key` should only be reused for retries of the same logical notification, not for every message in a thread by default + +--- + +## 6. Current Codebase Integration Map + +This section reflects the current backend code layout rather than the original pre-build implementation plan. + +### 6.1 Database and model ownership + +- `migrations/2026-03-07-120000_push_notifications_v1/{up,down}.sql` + - Defines `push_devices`, `notification_events`, and `notification_deliveries` plus active indexes and triggers. + +- `src/models/push_devices.rs` + - Owns device registration lookups, same-user reuse, revoke, invalidate, and active-device listing. + +- `src/models/notification_events.rs` + - Owns durable logical notification rows. + +- `src/models/notification_deliveries.rs` + - Owns delivery insertion, leasing, retry scheduling, and final state transitions. + +- `src/models/project_settings.rs` + - Owns `PushSettings`, `IosPushSettings`, and `AndroidPushSettings`. + +- `src/db.rs` + - Currently handles project push settings read / write helpers. + - The main device / event / delivery CRUD surface lives in the model modules above, not in `src/db.rs`. + +### 6.2 Web, runtime, and routing integration + +- `src/web/push.rs` + - App-user authenticated device register / list / revoke routes. + +- `src/web/agent/mod.rs` + - Maple agent SSE acknowledgement tracking plus the current disconnect-triggered `agent.message` enqueue path. + +- `src/web/platform/common.rs` + - Push secret constants and project-settings request / response types. + +- `src/web/platform/project_routes.rs` + - `GET` / `PUT /platform/orgs/:org_id/projects/:project_id/settings/push`. + +- `src/main.rs` + - Raw project secret helpers, route mounting, and background push-worker startup. + +### 6.3 Push delivery modules + +- `src/push/mod.rs` + - Shared types, enqueue helpers, generated `notification_id`, automatic `notif:` collapse keys, and current `agent.message` defaults. + +- `src/push/crypto.rs` + - P-256 ECDH + HKDF + AES-GCM envelope logic. + +- `src/push/apns.rs` + - APNs request building, JWT auth, cache invalidation, and response handling. + +- `src/push/fcm.rs` + - FCM OAuth token exchange, payload building, cached-token invalidation, and retry classification. + +- `src/push/worker.rs` + - Polling / leasing loop that sends pending deliveries. + +### 6.4 Deployment / runtime integration + +- `entrypoint.sh` + - Adds `/etc/hosts` entries and `traffic_forwarder.py` processes for APNs production, APNs sandbox, and FCM. + +- `docs/nitro-deploy.md` + - Documents the parent-instance `vsock-proxy` instructions for those outbound hosts. + +### 6.5 Codebase gotchas to preserve + +1. `src/main.rs` now exposes raw project-secret helpers for PEM / JSON consumption. + Keep using raw bytes / raw UTF-8 for APNs `.p8` material and FCM service account JSON rather than double-decoding base64. + +2. `src/encrypt.rs` is useful for **at-rest encryption** but not for device ECDH push envelopes. + Its current primitives take `secp256k1::SecretKey` and do symmetric encryption only. + +3. `src/main.rs::create_ephemeral_key()` uses **x25519** for session establishment. + Do not overload that codepath for push notifications. + +4. User-facing push APIs live under **`/v1/push/*`**, not under the older `/protected/*` namespace. + That matches the newer route style already used elsewhere in the repo. + +--- + +## 7. Project Configuration Model + +Current push config follows the same split already used elsewhere in the repo. + +### 7.1 Non-secret settings in `project_settings` + +The current implementation uses `SettingCategory::Push` and stores a JSON payload like: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PushSettings { + pub encrypted_preview_enabled: bool, + pub ios: Option, + pub android: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IosPushSettings { + pub enabled: bool, + pub bundle_id: String, + pub apns_environment: PushEnvironment, + pub team_id: String, + pub key_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AndroidPushSettings { + pub enabled: bool, + pub firebase_project_id: String, + pub package_name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PushEnvironment { + Dev, + Prod, +} +``` + +`encrypted_preview_enabled` controls whether the server attaches the encrypted preview envelope for supported devices. If disabled, the system still uses a generic provider-visible notification shell; it must **not** fall back to plaintext message content. + +Notes: + +- no new SQL table is required for settings; `project_settings` already supports new categories via JSONB +- `team_id` and `key_id` are identifiers, not secrets +- APNs environment stays explicit because sandbox vs production matters operationally + +### 7.2 Secrets in `org_project_secrets` + +The current implementation uses constants in `src/web/platform/common.rs`: + +```rust +pub const PROJECT_APNS_AUTH_KEY_P8: &str = "APNS_AUTH_KEY_P8"; +pub const PROJECT_FCM_SERVICE_ACCOUNT_JSON: &str = "FCM_SERVICE_ACCOUNT_JSON"; +``` + +Use the existing platform secret endpoints to store them. + +Important: + +- the existing platform secret API expects **base64-encoded raw bytes** +- APNs `.p8` content should be uploaded as raw PEM bytes, then base64-encoded for transport to the API +- FCM service account JSON should be uploaded as raw JSON bytes, then base64-encoded for transport to the API + +--- + +## 8. Data Model + +### 8.1 `push_devices` + +One row per active installation binding, with revoked rows retained for re-registration history and delivery auditing. + +```sql +CREATE TABLE push_devices ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT uuid_generate_v4() UNIQUE, + user_id UUID NOT NULL REFERENCES users(uuid) ON DELETE CASCADE, + + installation_id UUID NOT NULL, + platform TEXT NOT NULL CHECK (platform IN ('ios', 'android')), + provider TEXT NOT NULL CHECK (provider IN ('apns', 'fcm')), + environment TEXT NOT NULL CHECK (environment IN ('dev', 'prod')), + app_id TEXT NOT NULL, + + push_token_enc BYTEA NOT NULL, + push_token_hash BYTEA NOT NULL, + + notification_public_key BYTEA NOT NULL, + key_algorithm TEXT NOT NULL CHECK (key_algorithm IN ('p256_ecdh_v1')), + + supports_encrypted_preview BOOLEAN NOT NULL DEFAULT FALSE, + supports_background_processing BOOLEAN NOT NULL DEFAULT FALSE, + + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CHECK ( + (platform = 'ios' AND provider = 'apns') OR + (platform = 'android' AND provider = 'fcm') + ) +); + +CREATE INDEX idx_push_devices_user_active + ON push_devices(user_id, revoked_at); + +CREATE UNIQUE INDEX idx_push_devices_installation_active + ON push_devices(installation_id, environment) + WHERE revoked_at IS NULL; + +CREATE UNIQUE INDEX idx_push_devices_token_active + ON push_devices(provider, environment, push_token_hash) + WHERE revoked_at IS NULL; +``` + +Implementation notes: + +- `installation_id` is an app-generated UUIDv4 for one installed copy of the app; it is not an APNs or FCM identifier +- store `installation_id` in normal app-scoped local storage so uninstall / reinstall creates a fresh installation identity +- `push_token_enc` should be encrypted at rest with the enclave key using the same style as project secrets +- `push_token_hash` should be `SHA-256(push_token)` raw bytes for dedupe and lookup +- `notification_public_key` should store raw DER-encoded SPKI bytes, not a base64 string +- route-level validation and the DB constraint both enforce valid `(platform, provider)` pairs for defense in depth +- keep at most one active binding per `(installation_id, environment)` and per `(provider, environment, push_token_hash)`; older rows are revoked rather than overwritten + +### 8.2 `notification_events` + +Logical notification rows created by product code. + +```sql +CREATE TABLE notification_events ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT uuid_generate_v4() UNIQUE, + project_id INTEGER NOT NULL REFERENCES org_projects(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(uuid) ON DELETE CASCADE, + + kind TEXT NOT NULL, + delivery_mode TEXT NOT NULL, + priority TEXT NOT NULL DEFAULT 'normal', + collapse_key TEXT, + + fallback_title TEXT NOT NULL, + fallback_body TEXT NOT NULL, + payload_enc BYTEA, + + not_before_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + cancelled_at TIMESTAMPTZ, + + CHECK (delivery_mode IN ('generic', 'encrypted_preview')), + CHECK (priority IN ('normal', 'high')) +); + +CREATE INDEX idx_notification_events_user_due + ON notification_events(user_id, not_before_at, cancelled_at); +``` + +Implementation notes: + +- `payload_enc` should contain the encrypted preview payload or deep-link metadata encrypted at rest with the enclave key +- `fallback_title` and `fallback_body` are always explicit so the worker never has to invent fallback text at send time +- `notification_events.uuid` is the canonical `notification_id` exposed to clients and reused across retries + +### 8.3 `notification_deliveries` + +Per-device delivery records. + +```sql +CREATE TABLE notification_deliveries ( + id BIGSERIAL PRIMARY KEY, + event_id BIGINT NOT NULL REFERENCES notification_events(id) ON DELETE CASCADE, + push_device_id BIGINT NOT NULL REFERENCES push_devices(id) ON DELETE CASCADE, + + status TEXT NOT NULL DEFAULT 'pending', + attempt_count INTEGER NOT NULL DEFAULT 0, + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + lease_owner TEXT, + lease_expires_at TIMESTAMPTZ, + + provider_message_id TEXT, + provider_status_code INTEGER, + last_error TEXT, + sent_at TIMESTAMPTZ, + invalidated_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE (event_id, push_device_id), + CHECK ( + status IN ( + 'pending', + 'leased', + 'sent', + 'retry', + 'failed', + 'invalid_token', + 'cancelled' + ) + ) +); + +CREATE INDEX idx_notification_deliveries_pending + ON notification_deliveries(status, next_attempt_at); +``` + +Implementation notes: + +- deliveries should be materialized when the event is enqueued, not lazily at send time +- the worker should still re-check `push_devices.revoked_at` before sending + +--- + +## 9. API Surface + +### 9.1 App-user push device API + +The current router lives in `src/web/push.rs` and is mounted in `main.rs` with `validate_jwt` plus the usual encrypted request / response middleware. + +#### Register or rotate device + +```http +POST /v1/push/devices +``` + +Request payload: + +```json +{ + "installation_id": "uuid", + "platform": "ios", + "provider": "apns", + "environment": "prod", + "app_id": "ai.trymaple.ios", + "push_token": "opaque platform token string", + "notification_public_key": "base64-encoded SPKI DER", + "key_algorithm": "p256_ecdh_v1", + "supports_encrypted_preview": true, + "supports_background_processing": true +} +``` + +Behavior: + +- `installation_id` must be a non-nil UUIDv4 generated and stored by the app +- if the authenticated user already has a row for that installation, update it in place and clear `revoked_at` +- if another active row currently owns the same `installation_id` or the same push token, revoke that older binding before activating the current authenticated user +- rotate token / public key / capabilities when they change +- refresh `last_seen_at` + +#### List current user's devices + +```http +GET /v1/push/devices +``` + +Return metadata only, never the raw push token. + +#### Revoke one device + +```http +DELETE /v1/push/devices/:id +``` + +Behavior: + +- user-scoped revoke only +- set `revoked_at` +- optionally return the standard deleted-object response shape already used in newer APIs + +#### Client lifecycle expectations + +- first install: generate a fresh UUIDv4 `installation_id`, generate the notification keypair, fetch the APNs / FCM token, then call `POST /v1/push/devices` +- app start, login, or token rotation: call `POST /v1/push/devices` again; the route is idempotent for the current authenticated user and installation +- explicit logout: while the access token is still valid, call `DELETE /v1/push/devices/:id` for the current device before dropping auth +- account switch on the same installed app: revoke the old device binding on logout, then register again after the new user logs in; if the old binding is still active, the server treats the matching installation or token as a handoff and revokes the older binding +- uninstall / reinstall: create a new `installation_id` and notification keypair, then register as a new installation on next login; stale old rows are cleaned up by explicit revoke or later invalid-token handling + +#### Logout cleanup fallback + +The existing `/logout` request may also include an optional `push_device_id` field so the backend can best-effort revoke the current push binding during auth teardown: + +```json +{ + "refresh_token": "jwt", + "push_device_id": "uuid" +} +``` + +Preferred client flow: + +1. call `DELETE /v1/push/devices/:id` while the access token is still valid +2. call `/logout` +3. include `push_device_id` in `/logout` as a cleanup fallback in case the device revoke races with local auth teardown + +### 9.2 Platform project settings API + +The current project settings endpoints live in `src/web/platform/project_routes.rs`: + +```http +GET /platform/orgs/:org_id/projects/:project_id/settings/push +PUT /platform/orgs/:org_id/projects/:project_id/settings/push +``` + +Use existing platform auth, org membership checks, and the same validation style already used for email / OAuth settings. + +Do **not** add a dedicated secret endpoint. Reuse the existing generic project secret endpoints with: + +- `APNS_AUTH_KEY_P8` +- `FCM_SERVICE_ACCOUNT_JSON` + +--- + +## 10. Current Backend Module Split + +### 10.1 `src/db.rs` and push settings + +`src/db.rs` currently owns the project push settings helpers: + +- `get_project_push_settings(project_id)` +- `update_project_push_settings(project_id, settings)` + +This mirrors the existing email / OAuth settings pattern. + +### 10.2 Device / event / delivery DB logic + +Most push DB behavior currently lives in model modules rather than `src/db.rs`: + +- `src/models/push_devices.rs` + - installation lookups, active-token lookup, revoke, invalidate, and active device listing +- `src/models/notification_events.rs` + - event insertion and loading helpers +- `src/models/notification_deliveries.rs` + - bulk delivery insertion, leasing, retry scheduling, and `mark_*` transitions + +The delivery lease query uses Postgres row locking / leasing semantics, and retry scheduling uses Postgres time rather than enclave-local clock math. + +### 10.3 `src/push/mod.rs` enqueue behavior + +`src/push/mod.rs` currently owns: + +- `EnqueueNotificationRequest` +- generated canonical `notification_id` +- automatic `collapse_key = notif:` generation +- durable event creation plus per-device delivery materialization +- the current `agent.message` enqueue policy, including the 7-day expiry currently used by that path + +--- + +## 11. Worker Architecture + +### 11.1 Where the worker should start + +Initialize the worker from `src/main.rs` after: + +- app state build +- runtime migrations +- provider setup that already happens during boot + +Do **not** initialize it from request handlers. + +### 11.2 Worker structure + +Create `src/push/worker.rs` with a loop like: + +1. sleep / interval tick +2. lease due deliveries from Postgres +3. for each leased delivery: + - load event + device + project settings + - skip if device revoked or event cancelled / expired + - send via APNs or FCM + - update delivery status based on response + +Recommended v1 behavior: + +- poll every 2-5 seconds +- cap concurrent sends with a small semaphore +- exponential backoff on provider retryable failures + +### 11.3 Multi-enclave operation + +Run the worker in **every** OpenSecret backend / enclave instance. + +Shared Postgres is the coordination plane. + +Rules: + +- workers claim batches using row-level leases plus `FOR UPDATE SKIP LOCKED` +- no leader election is required +- any enclave can resume work after another enclave crashes +- `lease_owner`, `lease_expires_at`, `next_attempt_at`, and `status` must all live in Postgres +- final `mark_*` delivery transitions should only succeed when the current row still belongs to that worker's `lease_owner`; stale workers should become lost-lease no-ops instead of overwriting newer results + +### 11.4 Delivery semantics + +This design is **at-least-once**, not exactly-once. + +If a worker sends successfully to APNs / FCM but crashes before marking the row `sent`, another worker may retry after the lease expires. + +That is acceptable if we keep these protections in place: + +- stable `notification_id` +- one delivery row per `(event_id, push_device_id)` +- client-side recent-ID dedup cache +- provider collapse identifiers reused only for retries of the same logical notification + +### 11.5 Why Postgres outbox instead of SQS for v1 + +The repo already has some SQS plumbing, but v1 push delivery should still use Postgres as the source of truth. + +Why: + +- fewer moving parts +- easier local/dev operation +- delivery state stays queryable in one place +- no need to solve dual-write consistency on day one + +SQS can be added later as a wake-up optimization if volume requires it. + +--- + +## 12. Cryptographic Design + +### 12.1 What can be reused from the current repo + +Reusable: + +- `src/encrypt.rs::encrypt_with_key` for encrypting raw bytes at rest with the enclave key +- `src/encrypt.rs::decrypt_with_key` for decrypting those bytes +- existing `sha2` dependency for token hashing or HKDF backing hash + +Not reusable as-is: + +- current secp256k1 user-key flow +- current x25519 session setup in `AppState` +- any API that assumes a `secp256k1::SecretKey` is the transport key + +### 12.2 New dependencies to add + +Recommended new crates: + +- `p256` for P-256 ECDH and public-key parsing +- `hkdf` for key derivation + +Existing crates already cover the rest: + +- `aes-gcm` +- `sha2` +- `jsonwebtoken` +- `reqwest` + +### 12.3 Registered public-key format + +The client should register the public key as: + +- **base64-encoded DER SubjectPublicKeyInfo bytes** over the API +- stored as raw bytes in Postgres + +This is the best interoperability point because: + +- Android naturally exports X.509 / SPKI bytes +- iOS can export DER representation +- Rust `p256` can parse SPKI cleanly + +### 12.4 Envelope algorithm + +Use: + +- `P-256 ECDH` +- `HKDF-SHA256` +- `AES-256-GCM` + +Recommended envelope: + +```json +{ + "enc_v": 1, + "alg": "p256-hkdf-sha256-aes256gcm", + "kid": "push-device-uuid", + "epk": "base64-encoded ephemeral SEC1 public key", + "salt": "base64-encoded 32-byte salt", + "nonce": "base64-encoded 12-byte nonce", + "ciphertext": "base64-encoded AES-GCM ciphertext" +} +``` + +### 12.5 Plaintext preview payload + +```json +{ + "v": 1, + "notification_id": "uuid", + "message_id": "uuid", + "kind": "maple.reminder", + "title": "Reminder", + "body": "Follow up on the deployment thread", + "deep_link": "opensecret://agent/subagent/uuid", + "thread_id": "agent:uuid", + "sent_at": 1772800000 +} +``` + +`notification_id` should be identical to `notification_events.uuid` and remain stable across retries. + +Keep this intentionally small. + +For the initial implementation, normalize whitespace and truncate the preview `body` to a conservative byte budget before encryption so the final APNs payload stays comfortably under provider limits. + +### 12.6 At-rest encryption rules + +- encrypt `push_token_enc` with the enclave key +- encrypt `notification_events.payload_enc` with the enclave key +- do **not** store preview plaintext or raw push tokens in logs + +--- + +## 13. APNs Integration + +### 13.1 Endpoint and auth + +Send requests to: + +- production: `https://api.push.apple.com/3/device/` +- development: `https://api.sandbox.push.apple.com/3/device/` + +Use APNs token auth: + +- sign JWT with the stored `.p8` key +- header: `alg=ES256`, `kid=` +- claims: `iss=`, `iat=` +- refresh before one hour elapses; cache for about 50 minutes + +### 13.2 Required headers + +Use: + +- `authorization: bearer ` +- `apns-topic: ` +- `apns-push-type: alert` +- `apns-priority: 10` +- `apns-collapse-id: notif:` for the current logical notification event + +Retries reuse that same value because `notification_id` stays stable across retries. +Do **not** reuse one collapse ID for every message in a thread by default. +If desired, use `aps.thread-id` for notification-center grouping separately from collapse behavior. + +### 13.3 Payload shape for encrypted preview on iOS + +```json +{ + "aps": { + "alert": { + "title": "New Maple update", + "body": "Open Maple to view it" + }, + "mutable-content": 1, + "sound": "default" + }, + "os_meta": { + "notification_id": "...", + "kind": "agent.message", + "message_id": "...", + "thread_id": "agent:subagent:...", + "deep_link": "opensecret://agent/subagent/..." + }, + "os_push": { + "enc_v": 1, + "alg": "p256-hkdf-sha256-aes256gcm", + "kid": "...", + "epk": "...", + "salt": "...", + "nonce": "...", + "ciphertext": "..." + } +} +``` + +Important: + +- the fallback `alert.title` and `alert.body` must be non-empty +- the fallback alert must remain **generic**; never place plaintext assistant content in the provider-visible payload +- `mutable-content: 1` is required for the NSE to run +- if the NSE times out, iOS shows the original generic alert +- `os_meta` is allowed to carry minimal routing metadata (`notification_id`, `message_id`, `thread_id`, `deep_link`, `kind`), but not plaintext notification content +- this is intentional: the privacy requirement is protecting notification **content**, not hiding all routing metadata from the push provider + +### 13.4 APNs error handling + +Treat these as permanent invalid-token outcomes: + +- `410 Unregistered` + +Treat these as permanent non-retryable failures, but **do not** auto-revoke the device row based on them alone: + +- `400 BadDeviceToken` +- `400 DeviceTokenNotForTopic` + +Treat these as retryable: + +- `429` +- `500` +- `503` + +Persist: + +- HTTP status +- APNs `apns-id` +- failure reason string if returned + +Note: APNs token invalidation signals can be delayed. Use them for cleanup, but do not infer uninstall with certainty. + +--- + +## 14. FCM Integration + +### 14.1 Endpoint and auth + +Send requests to: + +```text +https://fcm.googleapis.com/v1/projects//messages:send +``` + +Use OAuth 2 service-account auth: + +1. load decrypted service account JSON +2. create a JWT assertion signed with the service-account RSA private key +3. exchange it at `https://oauth2.googleapis.com/token` (or the service account `token_uri`) +4. cache the returned access token until shortly before expiry + +Required scope: + +```text +https://www.googleapis.com/auth/firebase.messaging +``` + +### 14.2 Recommended Android v1 behavior + +For v1, prefer the standard messaging-app approach: + +- send a normal visible FCM notification with non-sensitive text +- include `data` fields for routing, grouping, and duplicate protection +- let the app fetch or render authoritative content after open + +This is the most standard and reliable path. +It avoids relying on background execution for every Maple message. + +### 14.3 Standard FCM payload for v1 + +```json +{ + "message": { + "token": "", + "notification": { + "title": "New Maple message", + "body": "Open Maple to view it" + }, + "data": { + "notification_id": "", + "kind": "agent.message", + "thread_id": "agent:uuid", + "message_id": "", + "deep_link": "opensecret://agent/subagent/uuid" + }, + "android": { + "priority": "HIGH", + "collapse_key": "notif:", + "ttl": "604800s", + "notification": { + "channel_id": "maple_messages", + "tag": "", + "click_action": "OPEN_THREAD" + } + } + } +} +``` + +Notes: + +- visible text should remain generic / non-sensitive +- FCM `data` values must be strings +- current backend derives Android TTL from `expires_at`; when an event has no explicit expiry, the sender defaults to 7 days +- the current `agent.message` enqueue path sets a 7-day expiry, so the common Maple-agent payload today is effectively close to `604800s` +- FCM `data` is provider-visible routing metadata, so it may include fields like `notification_id`, `message_id`, `thread_id`, `deep_link`, and `kind`, but never plaintext message content +- this is intentional in v1: the goal is to keep message content encrypted from the push provider, not to hide all metadata needed for client routing +- current implementation auto-keys `collapse_key` to `notification_id` without the older `sage:` prefix shown in earlier drafts +- reuse the same `collapse_key` only for the same logical notification +- `notification_id` is for exact dedup; `thread_id` is for grouping / clearing when the user opens the conversation + +### 14.4 Optional Android encrypted-preview path (deferred) + +If later we want closer parity with iOS: + +- send a **high-priority data-only** message +- `FirebaseMessagingService.onMessageReceived()` decrypts it +- app posts a local notification after decryption +- if the process does not run, the encrypted preview may not be shown + +That makes Android encrypted preview **best-effort**, not a required part of the v1 design. + +### 14.5 FCM error handling + +Treat these as permanent invalid-token outcomes: + +- `404 UNREGISTERED` +- `400 INVALID_ARGUMENT` when the error clearly identifies token invalidity + +Treat these as retryable: + +- `401` +- `403` +- `429` +- `500` +- `503` + +For `401` / `403`, the current implementation also invalidates the cached OAuth token first so the retry can fetch a fresh credential. + +Persist: + +- HTTP status +- returned FCM message name on success +- error code / status string on failure + +--- + +## 15. Mobile Client Requirements + +### 15.1 iOS + +#### Registration flow + +1. request notification permission +2. receive APNs device token +3. generate a dedicated P-256 notification keypair locally +4. store the private key in shared keychain storage so both the app and Notification Service Extension can read it +5. derive the public key in DER / SPKI form from that stored private key +6. register with `POST /v1/push/devices` + +#### Storage requirements + +The key must be accessible from both: + +- main app target +- Notification Service Extension target + +Recommended keychain accessibility: + +- `AfterFirstUnlockThisDeviceOnly`-style semantics + +This allows NSE access after first unlock while keeping the key device-bound. + +#### NSE behavior + +The Notification Service Extension should: + +1. read `userInfo["os_push"]` +2. load the private key from shared keychain storage +3. decrypt the envelope +4. rewrite `title`, `body`, and routing metadata +5. call the completion handler promptly + +If the key is unavailable or decryption fails, do nothing and let iOS show the generic fallback alert. + +Current Maple status: + +- implemented in the release / TestFlight lane +- shared key access is already wired for the main app and NSE targets +- notification toggle / logout cleanup is implemented + +#### Foreground / open-thread behavior + +If the app is already foregrounded and showing the target Maple conversation or active response screen: + +- implement `userNotificationCenter(_:willPresent:withCompletionHandler:)` +- return `[]` to suppress banner / sound / badge presentation for that in-app event +- clear previously delivered notifications for that `thread_id` when the user opens the conversation + +The NSE should not be treated as the primary “suppress if user is already looking” mechanism. For v1, successful SSE delivery is the primary suppression signal. + +### 15.2 Android + +#### Registration flow + +1. request notification permission on Android 13+ +2. obtain FCM registration token +3. generate a dedicated P-256 notification keypair locally +4. export / derive the public key as SPKI bytes +5. register with `POST /v1/push/devices` + +Current Maple status: + +- the current Android client uses Rust helper functions to generate the keypair and stores the PKCS#8 private key in `EncryptedSharedPreferences` +- moving that private key into Android Keystore remains future hardening, but it does not change the backend contract +- end-to-end push validation is currently intended for the release package `ai.trymaple.assistant` + +#### Runtime behavior + +- use `FirebaseMessagingService.onNewToken()` to rotate tokens +- for v1, assume the default incoming path is a standard visible FCM notification plus `data` +- if the app is already foregrounded, refresh the in-app chat state and do not show an extra local notification +- current notification taps route back into the main Maple chat surface and trigger a refresh; thread-specific notification clearing remains follow-up work +- if we later enable Android data-only encrypted preview, handle it in `onMessageReceived()` and only post a local notification after successful decrypt +- use `WorkManager` for longer processing if needed + +#### Direct Boot caveat + +Before first unlock after reboot, Android secure key material may be unavailable. + +So: + +- encrypted preview mode should degrade to app-open fetch or no preview +- if a notification must be visible pre-unlock, use generic mode instead + +### 15.3 Cross-platform client guidance + +#### Recent-ID dedup cache + +Each client should persist a small cache of recently seen `notification_id` values for at least 1-7 days. + +Rule: + +- if a notification arrives with an already-seen `notification_id`, treat it as a no-op + +This is the client-side protection against at-least-once retries or server bugs. + +Current Maple status: this cache is still recommended but is not yet implemented in the mobile clients, so provider collapse keys and server-side SSE suppression remain the main duplicate controls for the current v1 scope. + +#### Thread grouping and cleanup + +Each payload should include a `thread_id` when applicable. + +Use it to: + +- group notifications in app UI if desired +- clear thread notifications when the user opens that thread +- avoid leaving stale notifications around once the user has seen the conversation + +Current Maple status: thread-specific cleanup is not yet implemented consistently across Maple clients. That is follow-up hardening work rather than a blocker for the current disconnect-triggered v1 scope. + +#### Foreground suppression is defense-in-depth + +If a visible notification arrives while the app is already open, suppress or no-op locally where the platform allows it. + +But the primary v1 suppression rule is still server-side: + +- successful live SSE delivery => no push enqueue at all + +--- + +## 16. Nitro / Enclave Networking Changes + +### 16.1 Parent-instance allowlist additions + +Add these hosts to `/etc/nitro_enclaves/vsock-proxy.yaml`: + +```yaml +- {address: api.push.apple.com, port: 443} +- {address: api.sandbox.push.apple.com, port: 443} +- {address: fcm.googleapis.com, port: 443} +``` + +`oauth2.googleapis.com` is already in the repo's current setup and can be reused for the FCM access-token exchange. + +### 16.2 Parent-instance proxy services + +Following the existing patterns in `docs/nitro-deploy.md`, add new parent services such as: + +- `vsock-apns-prod-proxy.service` +- `vsock-apns-sandbox-proxy.service` +- `vsock-fcm-proxy.service` + +Example mappings: + +- APNs prod on parent port `8024` +- APNs sandbox on parent port `8025` +- FCM on parent port `8029` + +Any unused ports are acceptable; they just need to stay consistent with enclave `traffic_forwarder.py` mappings. + +### 16.3 Enclave `entrypoint.sh` changes + +Add new `/etc/hosts` entries and forwarders, for example: + +```sh +echo "127.0.0.21 api.push.apple.com" >> /etc/hosts +echo "127.0.0.22 api.sandbox.push.apple.com" >> /etc/hosts +echo "127.0.0.34 fcm.googleapis.com" >> /etc/hosts + +run_forever tf_apns_prod python3 /app/traffic_forwarder.py 127.0.0.21 443 3 8024 & +run_forever tf_apns_sandbox python3 /app/traffic_forwarder.py 127.0.0.22 443 3 8025 & +run_forever tf_fcm python3 /app/traffic_forwarder.py 127.0.0.34 443 3 8029 & +``` + +The exact IPs and ports can be changed, but they should use currently unused slots. + +--- + +## 17. Product Event Model + +### 17.1 Live Maple agent SSE flows + +For the common flow where the user sends a message to Maple and the client keeps an SSE stream open waiting for responses: + +1. generate and stream the Maple response as normal +2. if the final response is successfully delivered over that SSE stream, do **not** enqueue push +3. if the SSE stream closes or errors before final response delivery completes, enqueue exactly one notification event for that interrupted turn +4. if multiple assistant messages were generated after the disconnect point, use the first missed assistant message as the preview source for that one notification +5. that push fan-out then goes to all active devices + +This deliberately treats a successful live SSE stream as “the user likely saw it” and avoids more complex per-thread presence logic in v1. + +### 17.2 Internal enqueue API + +Do not create a public "send push" API in v1. + +Instead add an internal enqueue helper in `src/push/mod.rs` shaped roughly like: + +```rust +pub struct EnqueueNotificationRequest { + pub project_id: i32, + pub user_id: Uuid, + pub kind: String, + pub delivery_mode: PushDeliveryMode, + pub priority: PushPriority, + pub fallback_title: String, + pub fallback_body: String, + pub preview_payload: Option, + pub not_before_at: Option>, + pub expires_at: Option>, +} +``` + +The current helper generates `notification_id` internally and derives `collapse_key` as `notif:` when the event row is created. + +### 17.3 Initial event sources + +Current implemented backend source: + +- `src/web/agent/mod.rs` + - live Maple agent SSE completion / failure handling and one `agent.message` notification for an interrupted turn + +Future integrations: + +- `src/web/agent/runtime.rs` + - subagent follow-up or background completion signals + +- reminder scheduling, task-complete hooks, and account / security alerts + +Reminder scheduling can come later; the outbox design already supports `not_before_at`. + +### 17.4 Notification identity and duplicate protection + +- `notification_events.uuid` is the canonical `notification_id` +- include `thread_id` and `message_id` in payloads whenever applicable +- retries must reuse the same `notification_id` +- APNs `apns-collapse-id` and FCM `collapse_key` should key off that `notification_id` +- clients should keep a recent-ID cache and no-op duplicates + +--- + +## 18. Validation Coverage and Remaining Test Work + +### 18.1 Current unit coverage + +Current repo coverage includes focused tests for: + +- P-256 envelope roundtrip in `src/push/crypto.rs` +- preview normalization and enqueue-side helpers in `src/push/mod.rs` +- retry classification and backoff behavior in `src/push/worker.rs` +- push handoff planning and logout cleanup parsing in `src/web/push.rs` and `src/web/login_routes.rs` + +### 18.2 Remaining integration tests + +Add route tests for: + +- `POST /v1/push/devices` +- `GET /v1/push/devices` +- `DELETE /v1/push/devices/:id` +- `GET` / `PUT` platform push settings routes +- `/logout` push cleanup fallback +- same-device account handoff behavior across explicit revoke + re-register flows + +### 18.3 Provider client testability + +APNs and FCM sender endpoints are currently hardcoded. +For stronger provider-client tests, add test-only overrideable base URLs. + +That should make it possible to verify: + +- request headers +- request body shapes +- retry classification +- invalid-token handling + +--- + +## 19. Suggested Remaining Implementation Order + +### Phase 1: Delivery hardening + +- tighten permanent failure invalidation / quarantine behavior +- add targeted lost-lease transition tests around the current `mark_*` behavior +- add metrics for lost-lease and invalid-token outcomes + +### Phase 2: Provider testability and integration tests + +- add test-only APNs / FCM base URL overrides +- add route / lease / provider-client integration coverage + +### Phase 3: Product event source expansion + +- add reminder, task-complete, and account / security event hooks +- decide which event types should ship in the current backend scope versus later phases + +### Phase 4: Mobile client alignment and tuning + +- align mobile repos on recent-ID dedup, thread cleanup, and foreground suppression +- tune TTL, collapse-key policy, and other delivery defaults once real client behavior is exercised +- optionally add stronger device attestation later if the product needs it + +--- + +## 20. Rejected Alternatives + +### 20.1 Shared per-user notification key + +Rejected because it makes revocation and secure local storage worse. + +### 20.2 Sender outside the enclave + +Rejected for v1 because it weakens the trust boundary around payload plaintext and provider secrets. + +### 20.3 Silent-only push as the only strategy + +Rejected because silent / background behavior is too unreliable for user-facing alerts. + +### 20.4 Reusing existing user-key encryption APIs + +Rejected because those APIs are built around server-derived secp256k1 secrets and do not map cleanly to per-device mobile decryption. + +--- + +## 21. Final Recommendation + +The v1 implementation should be: + +- **direct APNs + direct FCM HTTP v1** +- **push sender inside the enclave app** +- **project-scoped provider config in `project_settings` + `org_project_secrets`** +- **per-device P-256 notification keypairs** +- **Postgres outbox + multi-enclave delivery worker coordinated via Postgres row leases** +- **successful live Maple SSE delivery suppresses push entirely** +- **iOS encrypted preview with generic fallback** +- **Android v1 uses standard visible FCM notifications plus data, with app-open fetch and local foreground suppression** +- **stable `notification_id` plus client-side dedup / thread cleanup** + +This is the simplest design that still matches the current OpenSecret architecture and avoids painting us into a corner later. + +Current implementation note: the backend in this repo already follows this design for the Maple agent disconnect path, while broader event sources and some hardening items remain follow-up work. + +--- + +## 22. Remaining Implementation Details + +These items are the main remaining hardening work after the current push handoff, delivery-isolation, and doc-alignment changes. + +### 22.1 Permanent failure hygiene + +- tighten device / config permanent-failure handling so obviously dead or ineligible targets are invalidated or quarantined more aggressively +- keep the worker from repeatedly retrying rows that are no longer realistically deliverable +- continue improving provider-specific invalid-token classification, especially for APNs / FCM permanent device failures + +### 22.2 Integration-level test coverage + +- add route-level tests for push registration, revoke, logout cleanup, and same-device account handoff +- add leasing tests that exercise stale-worker and lost-lease behavior against the actual DB transition code +- add provider-client tests with injectable APNs / FCM base URLs so delivery classification can be verified without real provider traffic + +### 22.3 Migration cleanup and consistency + +- clean up migration / down-migration consistency around index definitions where helpful + +### 22.4 Delivery policy flexibility + +- decide whether future callers need explicit internal enqueue control over `collapse_key`, TTL, or similar delivery-policy knobs +- document any chosen defaults centrally so backend and client expectations stay aligned + +### 22.5 Client parity and scope expansion + +- finish macOS encrypted-preview parity so the macOS notification service extension rewrites the generic fallback text like iOS +- add client-side `notification_id` dedup caches and thread cleanup where they materially improve UX +- extend backend event sources beyond the current `agent.message` Maple disconnect path when product priorities require it +- keep the mobile-client behavior docs aligned with what is actually implemented in the Maple repos diff --git a/docs/nitro-deploy.md b/docs/nitro-deploy.md index 8426a8ba..58d39b94 100644 --- a/docs/nitro-deploy.md +++ b/docs/nitro-deploy.md @@ -530,6 +530,108 @@ A restart of this should not be needed but if you need to: sudo systemctl restart vsock-apple-proxy.service ``` +## Vsock mobile push proxies +Create vsock proxy services so that enclave program can talk to APNs and FCM: + +First configure the endpoints into their allowlist: + +``` +sudo vim /etc/nitro_enclaves/vsock-proxy.yaml +``` + +Add these lines: +``` +- {address: api.push.apple.com, port: 443} +- {address: api.sandbox.push.apple.com, port: 443} +- {address: fcm.googleapis.com, port: 443} +``` + +Note: FCM OAuth token exchange reuses the existing Google OAuth proxy for `oauth2.googleapis.com` on port `8014`, so no additional oauth token proxy is needed here. + +These values should match the enclave-side mappings in `entrypoint.sh`: + +- `127.0.0.21 api.push.apple.com` -> parent port `8024` +- `127.0.0.22 api.sandbox.push.apple.com` -> parent port `8025` +- `127.0.0.34 fcm.googleapis.com` -> parent port `8029` + +Now create services that spin these up automatically: + +``` +sudo vim /etc/systemd/system/vsock-apns-prod-proxy.service +``` + +``` +[Unit] +Description=Vsock APNs Production Proxy Service +After=network.target + +[Service] +User=root +ExecStart=/usr/bin/vsock-proxy 8024 api.push.apple.com 443 +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +``` +sudo vim /etc/systemd/system/vsock-apns-sandbox-proxy.service +``` + +``` +[Unit] +Description=Vsock APNs Sandbox Proxy Service +After=network.target + +[Service] +User=root +ExecStart=/usr/bin/vsock-proxy 8025 api.sandbox.push.apple.com 443 +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +``` +sudo vim /etc/systemd/system/vsock-fcm-proxy.service +``` + +``` +[Unit] +Description=Vsock FCM Proxy Service +After=network.target + +[Service] +User=root +ExecStart=/usr/bin/vsock-proxy 8029 fcm.googleapis.com 443 +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +Activate the services: + +``` +sudo systemctl daemon-reload +sudo systemctl enable vsock-apns-prod-proxy.service +sudo systemctl start vsock-apns-prod-proxy.service +sudo systemctl status vsock-apns-prod-proxy.service +sudo systemctl enable vsock-apns-sandbox-proxy.service +sudo systemctl start vsock-apns-sandbox-proxy.service +sudo systemctl status vsock-apns-sandbox-proxy.service +sudo systemctl enable vsock-fcm-proxy.service +sudo systemctl start vsock-fcm-proxy.service +sudo systemctl status vsock-fcm-proxy.service +``` + +A restart of these should not be needed but if you need to: +``` +sudo systemctl restart vsock-apns-prod-proxy.service +sudo systemctl restart vsock-apns-sandbox-proxy.service +sudo systemctl restart vsock-fcm-proxy.service +``` + ## Vsock Resend proxy Create a vsock proxy service so that enclave program can talk to resend: diff --git a/entrypoint.sh b/entrypoint.sh index f4e56aaa..fa969369 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -323,6 +323,12 @@ log "Added Google OAuth domains to /etc/hosts" echo "127.0.0.15 appleid.apple.com" >> /etc/hosts log "Added Apple OAuth domain to /etc/hosts" +# Add push provider hostnames to /etc/hosts +echo "127.0.0.21 api.push.apple.com" >> /etc/hosts +echo "127.0.0.22 api.sandbox.push.apple.com" >> /etc/hosts +echo "127.0.0.34 fcm.googleapis.com" >> /etc/hosts +log "Added APNs and FCM domains to /etc/hosts" + # Add AWS SQS hostname to /etc/hosts echo "127.0.0.13 sqs.us-east-2.amazonaws.com" >> /etc/hosts log "Added AWS SQS domain to /etc/hosts" @@ -442,6 +448,16 @@ run_forever tf_os_flags python3 /app/traffic_forwarder.py 127.0.0.18 443 3 8028 log "Starting Apple OAuth traffic forwarder" run_forever tf_apple_oauth python3 /app/traffic_forwarder.py 127.0.0.15 443 3 8018 & +# Start the traffic forwarders for push providers in the background +log "Starting APNs production traffic forwarder" +run_forever tf_apns_prod python3 /app/traffic_forwarder.py 127.0.0.21 443 3 8024 & + +log "Starting APNs sandbox traffic forwarder" +run_forever tf_apns_sandbox python3 /app/traffic_forwarder.py 127.0.0.22 443 3 8025 & + +log "Starting FCM traffic forwarder" +run_forever tf_fcm python3 /app/traffic_forwarder.py 127.0.0.34 443 3 8029 & + # Start the traffic forwarders for Tinfoil proxy in the background log "Starting Tinfoil API GitHub proxy traffic forwarder" run_forever tf_tinfoil_api_github_proxy python3 /app/traffic_forwarder.py 127.0.0.16 443 3 8019 & @@ -610,6 +626,28 @@ else log "Apple OAuth connection failed" fi +# Test the connections to push providers +log "Testing connection to APNs production:" +if timeout 5 bash -c ' `agents` +- **shared core memory** -> `memory_blocks` +- **shared recall/archival storage** -> `user_embeddings` +- **thread-local compaction state** -> `conversation_summaries` + +--- + +## 5. Agent Context Assembly + +The main agent and every subagent use the same high-level regenerated-context pattern. + +### 5.1 Code-Owned Prompting, Not User-Owned Prompting + +The active agent runtime should load: +1. the `agents` row for the active agent +2. code-owned prompt template(s) for that agent kind +3. shared memory blocks for the user +4. user-global preferences for the user (at least `timezone` and `locale`) +5. the latest summary for the active conversation +6. recent messages for the active conversation + +**Prompt/model selection should be code-owned.** + +That means: +- no `system_prompt_enc` per user +- no per-user model selection row +- no per-user compaction threshold row +- no per-user max context row + +Instead, code chooses: +- default main-agent instruction +- default subagent instruction +- default model(s) +- context window / packing rules +- compaction thresholds + +User-global preferences still matter here: +- `timezone` should localize `current_time` and rendered conversation timestamps +- `locale` can inform prompt hints and future language behavior + +### 5.2 Main Agent vs Subagent Context + +The main agent and subagents share memory, but they do **not** share recent transcript. + +The context builder should behave like this: + +1. **Load active agent** from `agents` +2. **Build system prompt** + - start with code-owned instruction for `kind='main'` or `kind='subagent'` + - if subagent, inject decrypted `purpose_enc` + - inject shared `persona` and `human` blocks + - inject any relevant user-global preferences/context derived from `user_preferences` + - inject available tools + - inject memory metadata +3. **Load latest summary** for the active `conversation_id` +4. **Load recent messages** for the active `conversation_id` +5. **Pack within token budget** +6. **Compact old messages** in that same conversation when thresholds are reached + +A subagent should never silently get the main agent's recent transcript. If it needs information from another thread, it should use recall tools intentionally. + +### 5.3 Main-Agent Onboarding + +The main agent should feel warm and present from the first screen. A brand-new user should not land in a blank conversation waiting to type first. + +During `POST /v1/agent/init`, the backend should seed the first three assistant messages into the main-agent conversation history. + +That same init response should include those seeded messages directly so clients can replay them with normal typing / stagger behavior without making an immediate follow-up history call. + +Those first messages should be: +- server-authored +- persisted as real `assistant_messages` +- visible in normal conversation history APIs +- not frontend-hardcoded placeholders +- not model-generated by default + +Suggested default English copy for v1: + +1. `Hey, I'm Maple. 👋` +2. `Nice to meet you.` +3. `What should I call you?` + +After init, the main agent should continue using a stronger onboarding prompt overlay for approximately the first **10-15 user messages** in the main-agent thread. + +That onboarding window should be derived at prompt-build time from the count of **user messages** in the main-agent conversation. No separate persisted onboarding-state column is required in v1. + +During this onboarding window, Maple should: +- be especially warm, inviting, and emotionally open +- get to know the user gradually rather than interrogating them +- ask at most one thoughtful follow-up at a time +- proactively save key facts, routines, preferences, goals, and relationships to memory +- avoid transactional assistant language +- prioritize relationship-building when the user is simply arriving and getting settled + +Only actual user turns should count toward this window. Seeded assistant messages, tool calls, and tool outputs should not. + +### 5.4 DSRs Signature Shape + +The DSRs pattern still fits well. Conceptually the active agent call becomes: + +```text +AgentResponse (inputs) + - input + - current_time + - user_locale + - agent_kind + - subagent_purpose + - persona_block + - human_block + - memory_metadata + - previous_context_summary + - recent_conversation + - available_tools + - main_agent_user_message_count + +AgentResponse (outputs) + - messages: string[] + - tool_calls: {name: string, args: map}[] +``` + +`main_agent_user_message_count` lets code-owned prompt logic keep onboarding active for the first ~10-15 main-agent user turns without persisting separate onboarding state. + +### 5.5 Compaction vs Truncation + +This also stays the same conceptually: +- Responses API truncates +- agents compact + +Each agent thread owns its own summary chain. Main agent compaction and subagent compaction are independent because they are keyed by different `conversation_id`s. + +### 5.6 Vision / Image Handling + +No architectural change is needed here. + +The Maple-style approach still fits: +- preprocess image input into text +- inject the derived text into the conversation flow +- persist the derived text so it can be embedded and recalled later + +This should work the same way for the main agent and for subagents. + +--- + +## 6. Tools + +These remain the core tools for the shared-memory model: + +| Tool | Action | Storage | +|---|---|---| +| `memory_replace` | Replace text in a core memory block | `memory_blocks` | +| `memory_append` | Append text to a core memory block | `memory_blocks` | +| `memory_insert` | Insert text into a core memory block | `memory_blocks` | +| `archival_insert` | Store long-term memory | `user_embeddings` (`source_type='archival'`) | +| `archival_search` | Search long-term memory | `user_embeddings` (`source_type='archival'`) | +| `conversation_search` | Search embedded message history | `user_embeddings` (`source_type='message'`) | +| `set_preference` | Set validated user-global preferences such as timezone or locale | `user_preferences` | +| `spawn_subagent` | Create a topic-specific subagent chat | `conversations` + `agents` | +| `done` | Stop signal after tool-result continuation | no-op | + +### 6.1 Memory Tool Semantics + +For v1, core memory tools should operate on the built-in shared blocks only. + +That means: +- `persona` +- `human` + +This is still enough to get the product benefits of shared always-in-context memory without exposing a general-purpose user-editable block system. + +### 6.2 `conversation_search` Visibility + +By default, `conversation_search` should search across all eligible message embeddings for the user: +- main agent thread +- all subagent threads +- Responses API threads + +The tool may optionally accept a `conversation_id` filter to scope to one thread. + +### 6.3 `spawn_subagent` + +Subagent creation should be a first-class operation. + +A `spawn_subagent` tool should: +- create a new conversation row +- create a new `agents` row with `kind='subagent'` +- link it to the main agent via `parent_agent_id` +- store a display name and/or purpose +- return identifiers the client can use to hand off the user into that chat + +This is how the main agent can proactively create a focused workspace for the user. + +### 6.4 `set_preference` + +`set_preference` should exist for agent-managed user-global preferences. + +V1 known keys should be small and validated in code, for example: +- `timezone` -- IANA timezone like `America/Chicago` +- `locale` -- locale/language hint like `en` or `en-US` + +This tool is **not** a general-purpose config surface for prompt/model tuning. It is a narrow way for Maple to persist durable user-global context when the user explicitly shares it or when the client provides it during init. + +--- + +## 7. API Surface + +### 7.1 Public Agent-Facing Routes + +The public product surface should focus on the main agent and subagent lifecycle, not user configuration. + +The main agent should be created explicitly. Read/chat routes must not lazily initialize it as a side effect. + +```text +GET /v1/agent -- load the main agent or return 404 if not initialized +POST /v1/agent/init -- initialize the main agent, store init-time preferences, seed onboarding messages +GET /v1/agent/items -- read the main agent transcript/history +POST /v1/agent/chat -- chat with the main agent (request-scoped SSE; must not implicitly init) +POST /v1/agent/subagents -- create a new subagent +POST /v1/agent/subagents/:id/chat -- chat with a subagent (request-scoped SSE) +DELETE /v1/agent/subagents/:id -- delete a subagent +GET /v1/agent/events -- long-lived SSE for proactive delivery (post-MVP) +``` + +### 7.2 Routes That Should Not Be Public Product API + +These may exist temporarily as local debugging surfaces, but they should not be part of the target user-facing architecture: +- manual agent config update endpoints +- manual memory block CRUD endpoints +- manual archival insert/delete endpoints for normal product usage + +The product intent is **agent-managed memory**, not “settings panels for the user to tune Maple.” + +### 7.3 Conversation List Integration + +`/v1/conversations/*` should become the list/read/delete surface for: +- Responses API threads +- subagent chats + +It should **exclude**: +- the main agent thread + +The API should expose enough metadata to distinguish conversation kinds in the UI, e.g.: +- `response` +- `subagent` + +That distinction should come from the `agents` join / derived server response, not from making encrypted conversation metadata the canonical source of truth. + +### 7.4 Chat Event Model + +The SSE event model can stay the same for both main agent and subagents: +- `agent.typing` +- `agent.message` +- `agent.done` +- `agent.error` + +The difference is only which agent identity / conversation thread is being driven. + +--- + +## 8. Relationship to Other API Surfaces + +```text +/v1/responses/* + Stateless chat surface + Uses conversations + message tables + Threads remain visible in /v1/conversations + Eligible for shared recall embedding when policy allows + +/v1/agent/chat + Main agent home surface + Uses agents + shared memory + thread-local summaries + Requires prior explicit init + Main thread is hidden from /v1/conversations + +/v1/agent/init + Explicit main-agent bootstrap + Creates/repairs the main agent + conversation + Stores user-global preferences provided at init + Seeds the first assistant onboarding messages into history + Returns seeded onboarding messages directly in the init response + +/v1/agent/subagents/* + Subagent lifecycle + chat surface + Uses agents + shared memory + thread-local summaries + Subagent threads are visible in /v1/conversations + +/v1/conversations/* + Unified list/read/delete surface for response threads + subagent chats + Does not expose the main agent thread +``` + +The key data-flow rule is: +- all eligible stored messages from all three surfaces feed the same user-level recall memory +- all agents for that user can search that recall memory +- only thread-local history and summaries stay isolated per agent + +--- + +## 9. What We Are Not Deciding Yet + +These remain intentionally deferred: +- isolated subagent memory +- nested subagent trees beyond the “main agent -> subagent” model +- per-agent custom model selection +- per-agent custom prompt editing by users +- reminders / scheduling / background task execution +- code sandbox execution +- private/incognito threads excluding embedding +- agent-backed Responses API threads + +The important v1 decision is already made: **subagents share memory and search, but not transcript history.** + +--- + +## 10. Implementation Ordering + +Because this is still local-only, we should update the existing Maple docs and schema directly instead of preserving the old MVP shape. + +### Phase 1: RAG Foundation +- Keep `user_embeddings` +- Keep brute-force search + cache +- Keep experimental `/v1/rag/*` endpoints for validation if useful +- Add Responses API auto-embedding when the agent rewrite lands + +### Phase 2: Schema Reset (edit local-only migrations in place) +- Replace `agent_config` with `agents` +- Simplify `memory_blocks` +- Add `user_preferences` shared per user +- Keep `conversation_summaries` +- Keep `user_embeddings` shared per user +- Do **not** add per-agent memory scoping columns + +### Phase 3: Main Agent Runtime Rewrite +- Add explicit `POST /v1/agent/init` +- Load the main agent from `agents` +- Remove lazy main-agent creation from read/chat paths +- Move model/prompt/tuning defaults into code +- Remove user-facing config assumptions +- Localize `current_time` and transcript timestamps from `user_preferences.timezone` +- Seed the first three onboarding messages during init +- Keep richer onboarding guidance active for the first ~10-15 main-agent user turns +- Keep regenerated context + compaction + +### Phase 4: Subagent Lifecycle +- Create subagents through API and tool flow +- Give each subagent its own conversation + summary chain +- Inject subagent purpose into prompt assembly + +### Phase 5: Conversation List Integration +- Hide the main agent thread from `/v1/conversations/*` +- Show subagent threads there +- Continue showing Responses API threads +- Add derived conversation kind metadata for UI rendering + +### Phase 6: Remove Non-Product Surfaces +- Remove or internalize manual config endpoints +- Remove or internalize manual memory block endpoints +- Keep only the public surfaces that match the product model + +### Phase 7: Post-MVP +- Long-lived SSE event channel +- reminders / scheduling +- background delegation patterns +- optional future scoped memory if product requirements change + +**Overall implementation status:** target architecture documented; code rewrite still required. diff --git a/internal_docs/maple-emoji-reactions-plan.md b/internal_docs/maple-emoji-reactions-plan.md new file mode 100644 index 00000000..a9ed122a --- /dev/null +++ b/internal_docs/maple-emoji-reactions-plan.md @@ -0,0 +1,375 @@ +# Maple Emoji Reactions + +**Date:** March 2026 +**Status:** Implemented +**Related Docs:** +- `internal_docs/maple-agent-memory-architecture.md` +- `docs/encrypted-mobile-push-notifications.md` + +## Implementation Notes + +- `reply_reaction` is only available on the first assistant step of a turn. +- Continuation turns after tool results omit the `reply_reaction` field from the model output template entirely. +- User reaction mutation routes rely on the HTTP response; separate SSE fan-out for those mutations is out of scope in v1. + +--- + +## 1. Goal + +Add first-class emoji reactions to Maple conversations for both directions: + +- users reacting to assistant messages +- assistants reacting to the user message they are replying to + +This should be implemented as real message metadata in storage, API objects, and SSE, while also being rendered into the Maple prompt transcript so the model can see reaction context. + +--- + +## 2. Product Shape + +### 2.1 Supported v1 Behavior + +Each message can have at most one reaction from the opposite side: + +- a `user_messages` row may hold one assistant reaction +- an `assistant_messages` row may hold one user reaction + +This matches the current Maple chat model and keeps the first implementation small. + +### 2.2 Prompt Rendering + +Reactions should appear in the generated prompt transcript header, not as separate text in the message body. + +Example: + +```text +[user @ 10:42 AM | ❤️]: i got the job +[assistant @ 10:43 AM | 🎉]: NO WAY +``` + +The reaction glyph by itself is enough. We do not need extra wording like `assistant reacted`. + +--- + +## 3. Why Reactions Should Be First-Class + +Reactions should not be hidden inside prompt-only formatting or conversation-level metadata. + +They should be first-class because we need them to work consistently across: + +- database persistence +- normal item fetch/list APIs +- Maple SSE live delivery +- prompt construction for future turns + +The prompt rendering is a consumer of reaction data, not the source of truth. + +--- + +## 4. Storage Plan + +Use direct nullable columns on the existing shared message tables: + +```sql +ALTER TABLE user_messages + ADD COLUMN assistant_reaction TEXT; + +ALTER TABLE assistant_messages + ADD COLUMN user_reaction TEXT; +``` + +### 4.1 Why Direct Columns + +This is the smallest change that fits the current product requirements: + +- one reaction per message +- only the opposite side may react +- no reaction history required +- no multi-user aggregation required + +A separate `message_reactions` table can be introduced later if Maple needs multiple reactions, audit history, or multi-actor support. + +### 4.2 Encoding + +Use PostgreSQL `TEXT`. + +- PostgreSQL already supports UTF-8 text well +- emoji do not require a special column type +- we do not need encryption for reactions in v1 + +The main complexity is validation and normalization of multi-codepoint emoji, not storage type. + +--- + +## 5. API Model Plan + +Extend `ConversationItem::Message` with a first-class reaction field. + +Conceptually: + +```rust +Message { + id, + status, + role, + content, + reaction: Option, + created_at, +} +``` + +Interpretation: + +- for `role = "user"`, `reaction` means the assistant's reaction +- for `role = "assistant"`, `reaction` means the user's reaction + +This keeps the API simple for clients and avoids separate reaction wrapper objects in v1. + +### 5.1 Surfaces Covered + +Once threaded through shared message conversion, reactions will flow through: + +- `/v1/agent/items` +- `/v1/agent/items/:item_id` +- `/v1/agent/subagents/:id/items` +- `/v1/agent/subagents/:id/items/:item_id` +- shared conversation item APIs where those conversation rows are visible + +--- + +## 6. Assistant Reaction Generation + +Assistant reactions should be a first-class Maple agent output, not a tool call. + +Current Maple output is: + +```rust +messages: Vec +tool_calls: Vec +``` + +Implemented first-step Maple output: + +```rust +messages: Vec +reply_reaction: String +tool_calls: Vec +``` + +The model uses `""` when no first-step reaction is intended. + +Continuation turns after tool results use a reduced output shape with no `reply_reaction` field. + +### 6.1 Why Not a Tool + +Normal Maple tools currently imply all of the following: + +- execute a tool implementation +- inject a `[Tool Result: ...]` block into the next model turn +- persist `tool_call` and `tool_output` rows into transcript history + +That behavior is correct for real tools, but it is the wrong shape for emoji reactions. + +An assistant emoji reaction is closer to a first-class chat output, like a message, than to a tool execution. + +### 6.2 Targeting Semantics + +`reply_reaction` should implicitly target the current user message for the turn. + +No explicit message id needs to be exposed to the model in v1. + +--- + +## 7. Runtime Plan + +### 7.1 User -> Assistant Flow + +When a user sends a Maple message: + +1. persist the user message as normal +2. retain the inserted user message id/uuid in runtime state for the current turn +3. run the Maple agent loop +4. persist assistant `messages` as normal assistant message rows +5. on the first assistant step only, if `reply_reaction` is present, update the just-inserted user message row with `assistant_reaction` + +### 7.2 Why This Works Well + +- the model only needs to decide whether to react and which emoji to use +- runtime already knows which user message is being replied to +- no transcript noise is created from fake tool rows + +--- + +## 8. User Reaction Mutation Plan + +Add Maple-specific mutation endpoints for reacting to assistant messages. + +Suggested shape: + +- `POST /v1/agent/items/:item_id/reaction` +- `DELETE /v1/agent/items/:item_id/reaction` +- `POST /v1/agent/subagents/:id/items/:item_id/reaction` +- `DELETE /v1/agent/subagents/:id/items/:item_id/reaction` + +Expected behavior: + +- only assistant message items can receive a user reaction through these routes +- `POST` sets or replaces the reaction +- `DELETE` clears it + +Main-agent mutation routes should remain under `/v1/agent/...` because the public conversations API intentionally hides the main-agent thread. + +--- + +## 9. SSE Plan + +Maple SSE needs to become more structured so reactions can update live. + +### 9.1 Current Limitation + +Current `agent.message` events only send: + +```json +{ "messages": [...], "step": N } +``` + +That is too thin for live reactions because clients do not receive message item ids. + +### 9.2 Proposed Changes + +#### `agent.message` + +Include the persisted assistant message id with each delivered assistant message. + +Conceptually: + +```json +{ + "message_id": "uuid", + "messages": ["..."], + "step": 0 +} +``` + +If Maple continues to emit one assistant message per event, this shape is sufficient. + +#### `agent.reaction` + +Add a dedicated reaction event: + +```json +{ + "item_id": "uuid", + "emoji": "🫡" +} +``` + +We do not need an explicit `actor` field. In Maple, the target message role already implies who reacted: + +- reaction on a user message means assistant reacted +- reaction on an assistant message means user reacted + +In the implemented v1 scope, live `agent.reaction` SSE is used for assistant-generated reactions during chat turns. User `POST/DELETE .../reaction` mutations return the updated item over HTTP; separate multi-client fan-out is intentionally out of scope. + +--- + +## 10. Prompt Construction Plan + +Prompt building should include reaction metadata in the transcript header. + +This applies when rendering `recent_conversation` from stored thread items. + +Example rendering rules: + +- no reaction: + ```text + [user @ 10:42 AM]: hey + ``` +- with reaction: + ```text + [user @ 10:42 AM | ❤️]: hey + ``` + +This keeps the signal lightweight while still letting the model perceive social context. + +--- + +## 11. Validation / Normalization + +V1 accepts arbitrary emoji input without a fixed palette, but the server still applies lightweight normalization: + +- trim surrounding whitespace +- unwrap quoted JSON-string values +- reject empty strings +- enforce a 16-character maximum +- require a single grapheme cluster +- reject component-only values such as bare `1`, `#`, `*`, joiners, or variation selectors without an emoji base + +We should treat the stored value as a single reaction value, even if the emoji is composed of multiple Unicode codepoints. + +We do not need a fixed reaction palette in v1. + +--- + +## 12. File-Level Impact + +Expected implementation areas: + +- migrations for `user_messages` and `assistant_messages` +- `src/models/schema.rs` +- `src/models/responses.rs` +- `src/db.rs` +- `src/web/responses/conversions.rs` +- `src/web/responses/conversations.rs` +- `src/web/agent/runtime.rs` +- `src/web/agent/signatures.rs` +- `src/web/agent/mod.rs` + +Potentially also: + +- client-facing request/response structs for Maple reaction mutation routes +- Responses API item serializers if reaction support should also appear there + +--- + +## 13. Implementation Order + +Recommended order: + +1. add DB columns and model/schema wiring +2. extend raw conversation message queries to carry reaction fields +3. extend `ConversationItem::Message` with `reaction` +4. wire reactions through Maple item list/get routes +5. add Maple user-reaction mutation endpoints +6. add `reply_reaction` to Maple DSR output +7. update runtime to persist assistant reactions onto the current user message +8. extend Maple SSE with `message_id` and `agent.reaction` +9. render reactions into prompt transcript headers + +--- + +## 14. Non-Goals for v1 + +Not included in the first implementation: + +- multiple reactions per message +- reaction history / audit log +- arbitrary target-item reactions by the assistant +- cross-user aggregated reactions +- reaction counts +- custom reaction metadata objects beyond a single emoji string + +--- + +## 15. Summary + +The v1 Maple reaction design should be: + +- direct nullable reaction columns on message rows +- first-class `reaction` field on message items +- first-class first-step agent output `reply_reaction` +- continuation-step templates with no `reply_reaction` field +- live `agent.reaction` SSE events for assistant-generated reactions during chat +- prompt rendering using compact header metadata like `| ❤️` + +This keeps the feature native to Maple chat semantics without abusing the tool-call system. diff --git a/internal_docs/potential-rag-integration-brute-force.md b/internal_docs/potential-rag-integration-brute-force.md new file mode 100644 index 00000000..77cb9ff4 --- /dev/null +++ b/internal_docs/potential-rag-integration-brute-force.md @@ -0,0 +1,647 @@ +# OpenSecret RAG Architecture Proposal + +## Chat Memory via Encrypted Vector Search + +**Author:** Claude (with T), updated with Droid +**Date:** February 2026 +**Status:** Approved for Implementation + +--- + +## Executive Summary + +This proposal describes a RAG (Retrieval-Augmented Generation) system for OpenSecret that enables semantic search over encrypted user chat history and agent archival memory. The system stores per-message embeddings generated by the existing Tinfoil proxy, encrypted at rest with per-user keys, and performs brute-force cosine similarity search in-process inside the Nitro Enclave after decryption. + +The design prioritizes simplicity, correctness, and architectural consistency with OpenSecret's existing encryption model. It is divided into two parts: what to build now, and what to add as the system scales. + +### Revision Notes + +This document was revised after an architectural analysis comparing the OpenSecret backend with the Maple agent prototype (a privacy-first personal AI agent with a Letta-inspired 4-tier memory system). Key changes from the original proposal: + +- **Single database, not separate PlanetScale instance.** The vector workload doesn't justify a second database given brute-force in-process search. Eliminates cross-DB consistency concerns and operational complexity. +- **Narrowed v1 scope: chat + archival only.** Document ingestion/chunking and batch indexing are explicitly out of scope for the first implementation. +- **`user_embeddings` references messages via explicit BIGINT foreign keys.** Message-derived embeddings reference `user_messages.id` / `assistant_messages.id` with `ON DELETE CASCADE`, ensuring unambiguous provenance (user vs assistant) and strong deletion consistency. +- **Content duplication is retained (for now).** Even with explicit message FKs, storing the embedded text alongside the vector keeps search results and future re-embedding self-contained. A later optimization can drop duplicated message content and fetch it from the message tables only for the final top-k. +- **Explicit hooks for future agent integration.** The schema and API are designed so a Maple-style agent can use `archival_insert` / `archival_search` / `conversation_search` tools against this same storage layer. + +### Implementation Rollout Strategy + +The implementation is split into two phases: + +**Phase 1 (Now): Storage and search infrastructure.** +Build the `user_embeddings` table, Diesel models, encryption/serialization helpers, brute-force search engine, LRU cache, and a set of **experimental REST endpoints** (`/v1/rag/*`) for manual testing. These endpoints exist solely to validate the infrastructure before Maple lands and are **not intended for production client use**. No existing code paths (Responses API, etc.) are modified to auto-embed messages. + +**Phase 2 (When Maple lands): Agent integration and auto-embedding.** +- The Responses API message creation flow gains `tokio::spawn` hooks to auto-embed user and assistant messages after persistence. +- Maple's agent loop calls the storage/search layer directly via internal Rust functions (NOT via HTTP) using `archival_insert`, `archival_search`, and `conversation_search` tool implementations. +- The experimental REST endpoints are removed. All embedding reads/writes go through the agent's tool-use interface or internal auto-embedding hooks. No embeddings functionality is exposed over HTTP in production. +- `store=false` rejection is added to the Responses API handler at this point (when auto-embedding is active and the invariant matters). +- Billing hooks (`publish_usage_event_internal`) for auto-embedding are wired up at this point. + +--- + +## Part 1: What To Build Now + +### 1.1 Architecture Decision Summary + +| Decision | Choice | Rationale | +|---|---|---| +| Where does search run? | In-process, inside the existing Nitro Enclave | User keys are only available inside the enclave. No external vector DB can search encrypted vectors. | +| Search algorithm | Brute-force cosine similarity | At target per-user scale (<=40K vectors), brute force completes in ~4ms with 100% recall. No approximation errors, no index to maintain. | +| Vector storage | `user_embeddings` table in the **main PostgreSQL database** | Same encryption patterns, same connection pool, same Diesel migrations. No cross-DB consistency issues. Workload isolation can be achieved later via read replicas if needed. | +| Embedding model | `nomic-embed-text` via existing Tinfoil proxy (768 dimensions) | Already deployed at `localhost:8093`. Embeddings never leave the TEE chain. | +| Chunking strategy | One embedding per message (user and assistant separately) | Chat messages are short enough that per-message embedding captures intent well. Preserves granularity for retrieval. | +| Embedding generation | Server-side (user sends text, server calls Tinfoil) | Keeps the embedding model consistent. Removes client-side dependency on a specific model. | +| Content duplication | Store encrypted content copy in embeddings table | Keeps search results and future re-embedding self-contained (no extra DB round-trips to fetch/decrypt message content for the final top-k). Storage cost is negligible vs the vector itself. | + +### 1.2 Data Flow + +**Indexing (on message creation):** + +``` +User sends message + -> OpenSecret processes chat as normal + -> After storing encrypted message in main DB (existing flow) + -> Call Tinfoil proxy: POST localhost:8093/v1/embeddings + -> Receive 768-dim float vector + -> Encrypt vector with user's per-user key (AES-256-GCM) + -> Encrypt message text with user's per-user key (AES-256-GCM) + -> Store in user_embeddings: + (user_id, source_type='message', conversation_id, + user_message_id OR assistant_message_id, + vector_enc, content_enc, metadata_enc, token_count) +``` + +**Indexing (agent archival insert -- future):** + +``` +Agent decides to store a memory + -> Agent calls archival_insert tool with content + optional tags + -> Server calls Tinfoil proxy for embedding + -> Encrypt vector + content with user's per-user key + -> Store in user_embeddings with source_type = 'archival' +``` + +**Querying (on RAG-augmented chat or agent archival_search):** + +``` +User sends message (or agent triggers search) + -> Derive user key from enclave_key + seed_enc + -> Embed the query text via Tinfoil proxy + -> Load encrypted vectors from user_embeddings (streaming in batches of 1K) + -> For each batch: + -> Decrypt vectors in-process + -> Compute cosine similarity against query embedding + -> Maintain running top-k heap + -> Return top-k texts (decrypted) for context injection +``` + +### 1.3 Database Schema + +A single new table in the main PostgreSQL database, following existing Diesel migration conventions. + +#### `user_embeddings` table (v1 scope: chat history + archival memory) + +This v1 implementation intentionally narrows scope to two embedding sources: + +- `message`: auto-indexed chat history (user and assistant messages) +- `archival`: agent-inserted long-term memories (this row is the source of truth) + +Document ingestion/chunking and batch indexing are explicitly out of scope for v1. + +**Why explicit BIGINT message foreign keys (not `source_uuid`)** + +In PostgreSQL, a single column cannot be a foreign key to *either* `user_messages` *or* `assistant_messages`. A polymorphic `source_uuid` would be ambiguous (user vs assistant), hard to enforce, and would not give us `ON DELETE CASCADE` semantics for data consistency. + +Instead, message-derived embeddings reference the internal auto-incrementing IDs directly: + +- `user_message_id BIGINT REFERENCES user_messages(id) ON DELETE CASCADE` +- `assistant_message_id BIGINT REFERENCES assistant_messages(id) ON DELETE CASCADE` + +This aligns with OpenSecret conventions (BIGINT `id` for internal references; UUIDs for external API objects) and guarantees deletion consistency: + +- Deleting a `response` cascades to messages → cascades to embeddings. +- Deleting a `conversation` cascades to messages → cascades to embeddings. + +**Source type matrix (v1):** + +| `source_type` | `content_enc` | Message reference | `conversation_id` | Use case | +|---|---|---|---|---| +| `message` | Copy of message text | Exactly one of `user_message_id` / `assistant_message_id` | `conversations.id` (BIGINT FK) | Auto-indexed chat turns | +| `archival` | The passage itself (source of truth) | NULL | NULL | Agent stores "The user has a dog named Bob" | + +```sql +CREATE TABLE user_embeddings ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL UNIQUE, + user_id UUID NOT NULL REFERENCES users(uuid) ON DELETE CASCADE, + + -- Source tracking + -- v1 uses: 'message', 'archival'. We intentionally keep this as open TEXT. + source_type TEXT NOT NULL DEFAULT 'message', + + -- Message provenance (ONLY for source_type='message') + user_message_id BIGINT REFERENCES user_messages(id) ON DELETE CASCADE, + assistant_message_id BIGINT REFERENCES assistant_messages(id) ON DELETE CASCADE, + conversation_id BIGINT REFERENCES conversations(id) ON DELETE CASCADE, + + -- Embedding vector + vector_enc BYTEA NOT NULL, -- AES-256-GCM encrypted float32 array + embedding_model TEXT NOT NULL, -- e.g. "nomic-embed-text", for staleness detection + vector_dim INTEGER NOT NULL DEFAULT 768, + + -- Content that was embedded + content_enc BYTEA NOT NULL, -- AES-256-GCM encrypted text that was embedded + metadata_enc BYTEA, -- AES-256-GCM encrypted JSON (tags, optional extras) + + -- Plaintext metadata for querying/budgeting + token_count INTEGER NOT NULL, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Enforce invariants for the v1 source types without blocking future types. + CONSTRAINT user_embeddings_message_source_check CHECK ( + (source_type <> 'message') + OR ( + -- exactly one of the message FKs must be set + (user_message_id IS NOT NULL) <> (assistant_message_id IS NOT NULL) + ) + ), + CONSTRAINT user_embeddings_message_conversation_check CHECK ( + (source_type <> 'message') OR (conversation_id IS NOT NULL) + ), + CONSTRAINT user_embeddings_archival_source_check CHECK ( + (source_type <> 'archival') + OR ( + user_message_id IS NULL + AND assistant_message_id IS NULL + AND conversation_id IS NULL + ) + ) +); + +-- Primary query path: load all vectors for a user (brute-force scan) +CREATE INDEX idx_user_embeddings_user_id ON user_embeddings(user_id); + +-- For time-filtered searches (recency bias) +CREATE INDEX idx_user_embeddings_user_created ON user_embeddings(user_id, created_at DESC); + +-- For source-type filtered searches (message vs archival) +CREATE INDEX idx_user_embeddings_user_source ON user_embeddings(user_id, source_type); + +-- For conversation-scoped searches (message recall) +CREATE INDEX idx_user_embeddings_user_conversation ON user_embeddings(user_id, conversation_id); + +-- Idempotency/deduplication: prevent double-indexing the same message +CREATE UNIQUE INDEX idx_user_embeddings_user_message_id + ON user_embeddings(user_message_id) + WHERE user_message_id IS NOT NULL; + +CREATE UNIQUE INDEX idx_user_embeddings_assistant_message_id + ON user_embeddings(assistant_message_id) + WHERE assistant_message_id IS NOT NULL; + +-- For staleness queries: find vectors that need re-embedding after model change +CREATE INDEX idx_user_embeddings_model ON user_embeddings(user_id, embedding_model); + +-- Trigger for updated_at +CREATE TRIGGER update_user_embeddings_updated_at +BEFORE UPDATE ON user_embeddings +FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +``` + +**Column notes:** + +- `vector_enc`: Float32 array serialized to bytes using little-endian byte order (`f32::to_le_bytes()` / `f32::from_le_bytes()`), then AES-256-GCM encrypted with a random 12-byte nonce prepended. For 768 dims: 768×4 = 3,072 bytes, plus nonce overhead. +- `embedding_model`: Plaintext model identifier (e.g. `"nomic-embed-text"`) for staleness detection and re-embedding migrations. +- `vector_dim`: Plaintext dimension count for validation and potential future model upgrades. +- `user_message_id` / `assistant_message_id`: Internal message references (BIGINT) with hard `ON DELETE CASCADE`. Exactly one is set for `source_type='message'`. +- `conversation_id`: Internal conversation reference (BIGINT). Set for message embeddings to support efficient conversation-scoped search without joins. +- `content_enc`: The text that was embedded, encrypted with the user's per-user key. For `message`, this duplicates message text (keeps retrieval + future re-embedding self-contained). For `archival`, this is the source of truth. +- `metadata_enc`: Encrypted JSON. Recommended shapes: + - `archival`: `{tags: ["preferences"], description: "..."}` + - `message`: optional and generally unnecessary (provenance is determined by which FK column is set) +- `token_count`: Plaintext token count for budget management without decryption. + +#### Why a single table instead of per-source-type tables? + +Considered alternatives: +- **Separate tables per source type** (`message_embeddings`, `archival_embeddings`). Rejected: the data shape is almost identical and the search path is the same. Separate tables means duplicate Diesel models and duplicate search code. One table with a `source_type` discriminator keeps implementation simpler. +- **Adding `embedding_enc BYTEA` to existing message tables.** Rejected because: (1) not all messages should be embedded (tool calls, reasoning items, incomplete messages); (2) bloats the context builder's UNION ALL query with ~3 KB per row even when RAG isn't used; (3) couples embedding lifecycle to message lifecycle (re-embedding after model upgrade would require updating message rows); (4) archival passages have no corresponding message row. + +#### Why `source_type` is not a CHECK constraint + +The column uses open `TEXT` instead of `CHECK (source_type IN (...))`. This is intentional: adding a new source type later can start as an application-level change without immediately requiring a DB migration. The v1 CHECK constraints above only enforce invariants when `source_type` is one of the known v1 types (`message`, `archival`). + +### 1.4 Search Implementation + +Brute-force cosine similarity in Rust. No external dependencies beyond standard float math. + +```rust +fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { + let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum(); + let norm_a: f32 = a.iter().map(|x| x * x).sum::().sqrt(); + let norm_b: f32 = b.iter().map(|x| x * x).sum::().sqrt(); + dot / (norm_a * norm_b) +} +``` + +**Streaming batch approach:** + +Rather than loading all of a user's vectors in one query, use paginated batches: + +1. Query (keyset pagination): `SELECT id, vector_enc, content_enc, metadata_enc, token_count FROM user_embeddings WHERE user_id = $1 AND id > $2 ORDER BY id LIMIT 1000` +2. Decrypt the batch of vectors in-process +3. Compute cosine similarity for each against the query vector +4. Maintain a min-heap of size k (top-k results across all batches) +5. Repeat until no more rows +6. Return the top-k results with decrypted content + +Optional filters can be applied at the SQL level to reduce the scan set before any decryption happens: +- `AND source_type = 'archival'` -- search only archival memory +- `AND source_type = 'message'` -- search only chat history +- `AND source_type IN ('message', 'archival')` -- search multiple types +- `AND conversation_id = $3` -- search within a specific conversation (internal `conversations.id`) +- `AND created_at > $5` -- recency filter +- `AND embedding_model = $6` -- only search vectors from a specific model (useful during migration) + +For users with <=1,000 vectors (the vast majority), this completes in a single batch. + +### 1.5 In-Memory Cache (Simple LRU) + +A basic LRU cache of decrypted vector sets, keyed by `user_id`. Purpose: avoid re-loading and re-decrypting vectors on repeated queries within the same session. + +**Initial implementation:** + +- `HashMap` with a `VecDeque` for LRU ordering +- `CachedVectorSet`: the decrypted vectors + metadata, plus a timestamp +- **Max entries:** Start with 100 users cached simultaneously +- **Eviction:** LRU eviction when cache is full. Also evict entries older than 5 minutes (TTL). +- **Invalidation:** Evict the user's cache entry any time embeddings change (insert/update/delete). In practice: evict on successful inserts/updates; also evict on delete paths (delete-all embeddings and conversation deletion) so deleted content doesn't appear in search results until TTL expiry. +- **Memory budget:** 100 users x ~280 KB average (1,000 vectors x 280 bytes per decrypted entry) = ~28 MB. Conservative and safe for enclave memory. + +The cache is populated as a side effect of the streaming query: as batches are decrypted for search, the results are also written into the cache. Subsequent queries for the same user hit the cache directly and skip the DB entirely. + +This is deliberately simple. No distributed cache, no cross-instance coordination. Each enclave instance has its own independent LRU. With horizontal scaling, a user routed to a different instance gets a cache miss and loads fresh -- this is fine. + +### 1.6 API Design + +The production API surface for RAG is **internal only** -- no HTTP endpoints are exposed to clients. All embedding reads and writes happen through: + +1. **Auto-embedding hooks** in the Responses API message persistence flow (Phase 2, when Maple lands) +2. **Agent memory tools** -- `archival_insert`, `archival_search`, `conversation_search` -- called internally by the Maple agent loop as Rust function calls, not HTTP requests + +#### Experimental REST Endpoints (Phase 1 Only -- Will Be Removed) + +During Phase 1, a set of `/v1/rag/*` HTTP endpoints are provided **solely for development and testing** of the storage/search infrastructure. These endpoints: + +- Are **not documented** for SDK consumers +- Are **not called by any existing code path** (no auto-embedding, no Responses API integration) +- Will be **deleted before Maple ships to production** -- replaced by internal Rust function calls from the agent loop +- Serve as a convenient way to manually insert embeddings, run searches, and validate the system end-to-end during development + +#### Authentication and Encryption Middleware + +All `/v1/rag/*` experimental routes use the existing authentication and session encryption middleware, identical to the KV store and Responses API routes. This means: +- Session-based ECDH encryption (request/response bodies encrypted via `x-session-id`) +- User authentication via JWT (provides `Extension`, `Extension` session ID) +- Per-user key derivation via `get_user_key(user_uuid, None, None)` for encrypting/decrypting embeddings +- No new auth mechanisms or middleware are introduced. + +#### Experimental Endpoints (`/v1/rag/*`) + +**Auto-index chat messages (Phase 2 -- not built in Phase 1):** + +When Maple lands, message embeddings will be created automatically as a background `tokio::spawn` task after a `user_message` / `assistant_message` is persisted. **No auto-embedding hooks are added to existing code in Phase 1.** The auto-embedding flow is documented here for completeness: + +- There is **no client-facing endpoint** to index messages (not even experimentally). +- Each message embedding row uses: + - `source_type = 'message'` + - `conversation_id = conversations.id` + - exactly one of `user_message_id` / `assistant_message_id` set + +This gives us unambiguous provenance (user vs assistant) and hard deletion consistency via `ON DELETE CASCADE`. + +**Conversation UUID resolution (UUID -> BIGINT):** Externally, experimental APIs accept the public `conversations.uuid` for consistency with existing endpoints. For conversation-scoped search, resolve UUID -> internal `conversations.id` via `conversations::table.filter(conversations::uuid.eq(conv_uuid)).select(conversations::id).first::(conn)` (the `uuid` column has a unique index). The resolved internal ID is then used to filter on `user_embeddings.conversation_id`. + +**Insert archival memory (experimental -- tests the insert + embed + encrypt pipeline):** + +In production, archival inserts will be performed internally by the Maple agent via `archival_insert` tool calls. This endpoint exists for Phase 1 testing only. + +``` +POST /v1/rag/embeddings +{ + "text": "User prefers dark mode and concise responses", + "metadata": { "tags": ["preferences"] } // optional, encrypted as metadata_enc +} +-> 201 Created +{ + "id": "embedding-uuid", + "source_type": "archival", + "embedding_model": "nomic-embed-text", + "token_count": 8, + "created_at": "2026-02-09T..." +} +``` + +**Search (experimental -- tests the decrypt + brute-force + top-k pipeline):** + +In production, searches will be performed internally by the Maple agent via `conversation_search` and `archival_search` tool calls. + +``` +POST /v1/rag/search +{ + "query": "what did we discuss about deployment strategies?", + "top_k": 5, // default 5, max 20 + "max_tokens": 2000, // optional token budget for results + "source_types": ["message"], // optional: filter by source type(s), null for all + "conversation_id": "uuid" // optional: scope to specific conversation (public conversation UUID; server resolves to conversations.id) +} +-> 200 OK +{ + "results": [ + { + "content": "decrypted text...", + "score": 0.87, + "token_count": 145 + } + ] +} +``` + +**Search response shape (intentionally minimal):** The only required consumer is the agent/context-builder, which only needs decrypted text + ranking score (and `token_count` for packing within a context budget). Filtering happens in the query, not the response. By not returning any provenance fields (conversation/message IDs, source types, embedding IDs), the implementation avoids any BIGINT-to-UUID reverse-resolution and can simply decrypt the top-k `content_enc` values and return them. + +**Delete all embeddings for a user (account deletion / data reset):** + +``` +DELETE /v1/rag/embeddings +-> 204 No Content +``` + +Deletes all embeddings for the authenticated user. Also evicts the LRU cache entry. Note: this endpoint is likely to survive into production (useful for account deletion flows), unlike the insert/search endpoints. + +**Delete a specific embedding by ID:** + +``` +DELETE /v1/rag/embeddings/:id +-> 204 No Content +``` + +**Re-embedding support (model migration):** + +``` +GET /v1/rag/embeddings/status +-> 200 OK +{ + "total_embeddings": 4521, + "by_model": { + "nomic-embed-text": 4521 + }, + "stale_count": 0 // embeddings using a deprecated model +} +``` + +When a model is deprecated, a background job can query `WHERE embedding_model != 'current-model'`, decrypt the `content_enc`, re-embed via Tinfoil, and update `vector_enc` + `embedding_model`. The `content_enc` being always present is what makes this possible without needing to join back to source tables. + +### 1.7 Data Visibility and Isolation + +A key design decision: **which messages get embedded, and who can search what?** + +#### The Model + +One user has three ways to interact with Maple: + +1. **Responses API threads** (`/v1/responses/*`) -- stateless, one-off conversations. A user can have many of these. +2. **Main agent** (`/v1/agent/chat`) -- a single persistent home agent with one long-running thread. This thread is hidden from the generic conversation list. +3. **Subagent chats** -- topic-specific agent conversations. A user can have many of these. Each subagent has its own long-running thread and appears in `/v1/conversations/*` like a first-class chat. + +The main agent and all subagents share the same user-level memory layer. What differs between them is only thread-local history and summaries. + +#### Default: Broad Visibility + +**All stored messages from all three surfaces are embedded into `user_embeddings` when eligible.** An agent's `conversation_search` searches across all of the user's messages by default -- main-agent messages, subagent messages, and Responses API thread messages. + +Rationale: the entire agent system is the user's persistent memory layer. If a user had a detailed Responses API conversation about Kubernetes deployment strategies, or already discussed the topic in another subagent, then asks the main agent or a new subagent about deployment, that agent should surface that knowledge. Users who opt into the agent system expect cross-conversation memory. That's the whole point. + +The `conversation_id` column on each embedding enables filtering when needed: +- **Broad search (default):** `WHERE user_id = $1` -- agent sees everything +- **Main-thread-only search:** `WHERE user_id = $1 AND conversation_id = $main_agent_conversation_id` +- **Specific subagent or Responses thread search:** `WHERE user_id = $1 AND conversation_id = $specific_thread_id` + +Since search is brute-force in-process, these filters are just WHERE clauses applied before decryption. Zero architectural cost to support any combination. + +#### `store=false` Rejection (Phase 2) + +OpenSecret does not support `store=false`. The `store` field exists on `ResponsesCreateRequest` (defaults to `true`) and is persisted on the `responses` row, but no code path currently changes behavior based on it. **When auto-embedding is activated in Phase 2**, requests with `store=false` will be rejected with a 400 Bad Request error, added early in the Responses API handler (in `handlers.rs`), before any DB writes or streaming begins. This is deferred to Phase 2 because the rejection only matters when auto-embedding is active and the "all stored messages get embedded" invariant needs enforcement. + +A future `private` / incognito flag on conversations may allow users to have Responses API threads that are excluded from embedding entirely. This is out of scope for v1. + +### 1.8 Integration Points + +#### Phase 1: No Integration With Existing Code + +**In Phase 1, no existing code paths are modified.** The Responses API, conversations system, and all other handlers remain untouched. The RAG infrastructure (table, models, search engine, cache) is built as a standalone module with experimental HTTP endpoints for testing. This keeps Phase 1 low-risk and independently deployable. + +#### Phase 2: Responses API Auto-Embedding (When Maple Lands) + +The Responses API message creation flow will gain background embedding hooks: + +``` +ResponsesCreateRequest (store=false is rejected with 400 before reaching this point) + -> persist user_message (existing) + -> persist assistant_message (existing, after streaming) + -> [async, tokio::spawn] embed user_message and store in user_embeddings + -> [async, tokio::spawn] embed assistant_message and store in user_embeddings +``` + +Embedding failures are non-fatal. Messages are stored regardless. Embeddings are a best-effort enrichment. + +**Async embedding via `tokio::spawn` (no formal queue):** + +Embedding is fired as a `tokio::spawn` task after the message is persisted and the response stream completes. This is a simple fire-and-forget async task -- no background queue, no worker pool, no separate infrastructure. At current scale, concurrent Tinfoil proxy calls from simultaneous users are not a concern. If this becomes a bottleneck (e.g., thundering herd during peak usage), a bounded semaphore or formal queue can be added later. Embedding failures are logged but non-fatal -- the message is already stored. The unique indexes on `user_message_id` / `assistant_message_id` ensure one embedding per message, making retries or future backfill safe and idempotent. + +#### Phase 2: Billing (User-Billed, Activated With Auto-Embedding) + +Auto-indexing embedding calls will be billed per-user, identical to the existing `/v1/embeddings` endpoint. The existing `proxy_embeddings` handler already calls `publish_usage_event_internal` with `prompt_tokens` after every Tinfoil proxy call. The RAG auto-embedding path will reuse the same `publish_usage_event_internal` function with the same model name (`nomic-embed-text`) and prompt token accounting. No new billing mechanism is needed. **In Phase 1, no billing hooks are added** since there is no auto-embedding; any manual testing via experimental endpoints does not generate billing events. + +#### Phase 2: Agent System Integration (When Maple Lands) + +The agent will call the storage/search layer directly via internal Rust functions -- **not via HTTP**: + +- `archival_insert` -> calls the embed + encrypt + store function internally with `source_type: "archival"` +- `archival_search` -> calls the search function internally with `source_types: ["archival"]` +- `conversation_search` -> calls the search function internally with `source_types: ["message"]` (default: all conversations, not just the agent's own thread) + +The agent's context builder injects search results into the system prompt as memory blocks, rather than the Responses API's current middle-truncation approach. The experimental HTTP endpoints are removed at this point. + +### 1.9 What This Gets You + +For a typical user with a year of chat history (~2,000-4,000 messages -> 4,000-8,000 embeddings): + +- **Storage:** ~28-56 MB per user in the embeddings table +- **Search latency:** Single-digit milliseconds (brute force on cached vectors) to ~50ms (cold start, streaming from DB) +- **Accuracy:** 100% recall -- true nearest neighbors, not approximate +- **Privacy:** Identical to existing encryption model. Vectors encrypted at rest with per-user keys. Decrypted only in-enclave, in-process. No external vector DB has access to plaintext. + +--- + +## Part 2: Scaling Roadmap + +This section describes what to watch, when to act, and what to build as the system grows. These are ordered by when you're likely to need them. + +### 2.0 Operational note: Postgres maintenance (vacuum / bloat) + +Keeping `user_embeddings` in the primary database is the simplest operational model, but it does give up the vacuum isolation benefit of a separate DB. Large re-embedding jobs (mass `UPDATE`) and high churn (`DELETE` from user resets / conversation deletions) will generate dead tuples that autovacuum must clean up, potentially competing with hot-path tables. If this becomes a real issue, mitigate with: small-batch re-embedding, per-table autovacuum tuning, table partitioning, and/or moving scan-heavy reads to a replica (or splitting databases later). + +### 2.1 Monitoring: What to Instrument From Day One + +You need visibility into the vector workload to know when scaling pressure is real vs. theoretical. Add metrics for: + +| Metric | What it tells you | Watch threshold | +|---|---|---| +| `rag.search.latency_p99` | End-to-end search time including DB load + decrypt + search | >100ms | +| `rag.search.db_load_ms` | Time spent loading encrypted vectors from DB | >50ms | +| `rag.search.decrypt_ms` | Time spent decrypting vectors | >20ms | +| `rag.search.compare_ms` | Time spent on cosine similarity | >10ms | +| `rag.cache.hit_rate` | LRU cache effectiveness | <60% | +| `rag.cache.memory_bytes` | Total memory consumed by cached vector sets | Approaching enclave memory limit | +| `rag.user.vector_count` | Distribution of per-user vector counts | p99 approaching 50K | +| `rag.index.insert_latency` | Time to embed + encrypt + store a new vector | >200ms | + +### 2.2 Scaling Tier 1: Cache Optimization (Likely Needed at ~5K-10K DAU) + +**Trigger:** Cache hit rate drops below 70%, or enclave memory pressure increases. + +**What to do:** + +- **Tiered cache:** Store only the vectors (not the content text) in the hot cache. Content is only decrypted for the final top-k results, not the full scan set. This cuts cache memory per user by ~40%. +- **Adaptive TTL:** Power users who query frequently get longer TTLs. Users with a single query per session get evicted faster. +- **Cache warming on session start:** When a user authenticates, pre-load their vectors into the cache asynchronously before any RAG query is triggered. +- **Compressed vectors:** Store vectors as float16 in the cache (not in the DB -- the DB stores full precision encrypted vectors). Half-precision is sufficient for cosine similarity ranking and halves cache memory. + +### 2.3 Scaling Tier 2: Query Optimization (Likely Needed at ~10K-25K DAU) + +**Trigger:** `rag.search.db_load_ms` consistently above 50ms for power users, or `rag.search.latency_p99` above 200ms. + +**What to do:** + +- **Pre-filtering by time window:** Add a `created_at` filter to the vector query. Most RAG use cases for chat memory benefit from recency bias -- searching the last 6 months instead of all history cuts the scan set significantly. The `idx_user_embeddings_user_created` index supports this efficiently. +- **Source-type filtering:** When the agent only needs archival memory or only chat history, filter at the SQL level using `source_type`. +- **Token budget pre-filtering:** If the request includes `max_tokens`, use the plaintext `token_count` column to estimate how many results you need and stop scanning early once you have enough high-scoring candidates to fill the budget. +- **Parallel batch decryption:** Decrypt vector batches on a thread pool (Rayon or Tokio spawn_blocking) while the next batch is being fetched from the DB. Overlaps CPU work with I/O wait. + +### 2.4 Scaling Tier 3: Index Structures (Likely Needed When p99 Users Hit ~50K+ Vectors) + +**Trigger:** `rag.search.compare_ms` consistently above 30ms for power users, and time-window pre-filtering isn't sufficient because users need deep history search. + +You are unlikely to need this for ~12-18 months; brute-force scan + caching should carry the initial product. Treat ANN as an emergency lever once power users' vector sets get large enough to matter. + +**What to do:** + +This is where you introduce an in-process approximate nearest neighbor (ANN) index. The recommended path is: + +**Option A: `instant-distance` (pure Rust HNSW)** + +- Pure Rust, no C++ dependencies, no BLAS/LAPACK +- Builds cleanly inside a Nitro Enclave without cross-compilation pain +- Hierarchical Navigable Small World (HNSW) graph -- 95-99% recall at orders of magnitude faster than brute force for large vector sets +- Build the index from decrypted vectors on cache warm-up, query the index instead of doing a linear scan +- Index building is the slow part (~100ms for 50K vectors), but it's a one-time cost per cache entry + +**Option B: FAISS (via `faiss-rs` bindings)** + +- More mature, more index types (IVF, PQ, HNSW, flat) +- Significantly harder to build inside a Nitro Enclave (C++ library, BLAS dependency) +- Only worth the build complexity if `instant-distance` becomes insufficient + +**In both cases, the architecture doesn't change.** Vectors are still encrypted at rest in PostgreSQL, decrypted in-process, and the index is built ephemerally from decrypted vectors. The DB remains the source of truth. + +### 2.5 Scaling Tier 4: Storage Optimization (Likely Needed at ~100K+ Users) + +**Trigger:** Vector table exceeds 500 GB, or storage costs become a concern. + +**What to do:** + +- **Dimensional reduction:** If `nomic-embed-text` supports Matryoshka representations (v1.5 does), truncate embeddings from 768 to 256 or 384 dimensions at indexing time. Cuts storage by 50-67% with modest recall loss. Requires re-embedding existing vectors. +- **Quantized storage:** Store vectors as int8 instead of float32 in the DB (4x storage reduction). Decrypt and upcast to float32 for search. +- **Archival tiering:** Move embeddings older than N months to cold storage (S3 as encrypted blobs). Only search hot vectors by default. +- **Content deduplication:** For `message` source type, drop `content_enc` and join back to the main message tables for the final top-k results only. The cross-table join only happens for k results (5-20), not the full scan set. + +--- + +## Appendix A: Size Estimates + +### Per-User Storage + +| User type | Messages/year | Embeddings/year | Storage/year | +|---|---|---|---| +| Light (casual chat) | 500 | 1,000 | 7 MB | +| Medium (regular use) | 2,000 | 4,000 | 28 MB | +| Heavy (power user) | 10,000 | 20,000 | 140 MB | +| Max projected (2yr heavy) | 20,000 | 40,000 | 280 MB | + +### Fleet-Wide Storage (Projected 1 Year) + +| Scenario | Total users | Avg embeddings/user | DB size | +|---|---|---|---| +| Current scale | 2,500 MAU | 2,000 | ~35 GB | +| 10x growth | 25,000 MAU | 2,000 | ~350 GB | +| 10x growth + power users | 25,000 MAU + 500 power | mixed | ~430 GB | + +### Search Latency (Brute Force, 768 Dimensions) + +| Vectors per user | Cosine similarity time | Total w/ DB load (cold) | Total w/ cache (warm) | +|---|---|---|---| +| 1,000 | <1 ms | ~20 ms | <2 ms | +| 10,000 | ~1 ms | ~50 ms | ~3 ms | +| 40,000 | ~4 ms | ~200 ms | ~6 ms | +| 100,000 | ~10 ms | ~500 ms | ~15 ms | + +## Appendix B: Encryption Consistency + +The vector storage follows the exact same encryption pattern as existing OpenSecret data: + +| Existing pattern | Vector DB equivalent | +|---|---| +| `user_messages.content_enc` (AES-GCM) | `user_embeddings.content_enc` (AES-GCM) | +| `user_messages.prompt_tokens` (plaintext int) | `user_embeddings.token_count` (plaintext int) | +| `conversations.metadata_enc` (AES-GCM JSON) | `user_embeddings.metadata_enc` (AES-GCM JSON) | +| `user_kv.value_enc` (AES-GCM) | `user_embeddings.vector_enc` (AES-GCM) | +| Key: `get_user_key(user_uuid, None, None)` | Key: `get_user_key(user_uuid, None, None)` | + +No new encryption primitives, key derivation paths, or trust boundaries are introduced. + +## Appendix C: What We're Not Building (Yet) + +For clarity, these are explicitly out of scope. Items are grouped by phase. + +**Out of scope for Phase 1 (infrastructure only):** + +- **Auto-embedding hooks in the Responses API.** No existing code paths are modified. No `tokio::spawn` embedding tasks. This is Phase 2 work. +- **Agent memory tools.** The `archival_insert` / `archival_search` / `conversation_search` tools are designed for but not implemented until Maple lands (Phase 2). +- **`store=false` rejection.** Deferred to Phase 2 when auto-embedding makes the invariant meaningful. +- **Billing hooks for auto-embedding.** No `publish_usage_event_internal` calls from RAG code in Phase 1. +- **Production HTTP API for embeddings.** The Phase 1 experimental endpoints are for testing only and will be removed when Maple ships. In production, all embedding operations go through internal Rust function calls from the agent, not HTTP. + +**Out of scope for both phases (future work):** + +- **Document ingestion / chunking.** v1 only supports chat-history embeddings (`source_type='message'`) and agent archival memory (`source_type='archival'`). Document indexing can be added later with a separate design + migration. +- **Cross-user search** -- architecturally impossible by design and not desired. +- **Automatic RAG injection into Responses API** -- the Responses API stays stateless; RAG injection happens through the agent system. +- **Re-embedding background job.** The schema tracks `embedding_model` per vector and the status endpoint reports staleness. But the actual background re-embedding worker is not part of v1. Re-embedding can be triggered manually or built as a follow-up. +- **Hybrid search (keyword + semantic)** -- brute-force semantic search only for now. +- **External vector database (Pinecone, Weaviate, etc.)** -- incompatible with per-user encryption model. All search must happen inside the enclave. + +## Appendix D: Test Strategy + +**Phase 1 tests** validate the infrastructure in isolation: + +- **Unit tests:** Cosine similarity correctness (known vectors with known scores), vector serialization/encryption round-trips (f32 array -> LE bytes -> AES-GCM encrypt -> decrypt -> LE bytes -> f32 array), and top-k heap behavior (verify correct ranking and tie-breaking). +- **Integration tests:** Cover the experimental `/v1/rag/*` endpoints (archival insert, search, delete, status) using the existing test harness patterns from the Responses API and KV store tests. These tests exercise the full pipeline: HTTP request -> embed via Tinfoil -> encrypt -> store -> search -> decrypt -> response. + +**Phase 2 tests** (when Maple lands) will additionally cover: +- Auto-embedding hooks: verify that creating a message via the Responses API produces a corresponding `user_embeddings` row. +- Agent tool integration: verify `archival_insert`, `archival_search`, `conversation_search` tool calls produce correct results through the internal Rust API. +- Deletion cascades: verify that deleting a conversation/response cascades to embeddings. +- Billing: verify that auto-embedding generates `publish_usage_event_internal` calls. diff --git a/internal_docs/push-notifications-implementation-scratchpad.md b/internal_docs/push-notifications-implementation-scratchpad.md new file mode 100644 index 00000000..bd3c94b4 --- /dev/null +++ b/internal_docs/push-notifications-implementation-scratchpad.md @@ -0,0 +1,110 @@ +# Push Notifications Implementation Scratchpad + +## 2026-03-07 — Kickoff + +- Started backend implementation for the mobile push notification system. +- Moved the main design doc from `internal_docs/encrypted-mobile-push-notifications.md` to `docs/encrypted-mobile-push-notifications.md` so it can be shared with app developers. +- Re-read the design doc before starting implementation. +- Current implementation constraints being followed: + - successful live Maple SSE delivery suppresses push in v1 + - Postgres is the durable coordination plane across multiple enclaves + - Android v1 uses standard visible FCM notifications plus `data` + - iOS keeps encrypted preview support with generic fallback + - stable `notification_id` is required for retry-safe client dedup +- Initial audit focus: + - agent SSE flow in `src/web/agent/mod.rs` + - project settings + secrets patterns already used in platform routes and DB helpers + +## 2026-03-07 — Scope correction + +- User clarified that implementation scope is **Maple agent APIs only** for now. +- Do **not** modify or depend on the Responses API implementation for this push work. +- Current architecture question discovered during audit: + - agent chat generation currently runs inside the SSE stream in `src/web/agent/mod.rs` + - if the client disconnects, generation likely stops with the stream + - to implement “SSE success suppresses push; disconnect triggers push,” we may need to refactor agent generation to continue in a background task after disconnect + +## 2026-03-07 — Section 1 complete: schema and core model scaffolding + +- Re-read the push design doc sections covering data model, worker leasing, and notification identity before writing schema changes. +- Added a new migration: `2026-03-07-120000_push_notifications_v1`. +- Added schema for: + - `push_devices` + - `notification_events` + - `notification_deliveries` +- Added model files: + - `src/models/push_devices.rs` + - `src/models/notification_events.rs` + - `src/models/notification_deliveries.rs` +- Updated `src/models/mod.rs` and `src/models/schema.rs` to register the new tables. +- Extended `src/models/project_settings.rs` with typed push settings (`PushSettings`, `IosPushSettings`, `AndroidPushSettings`, `PushEnvironment`) and `SettingCategory::Push`. +- Added typed DB helpers in `src/db.rs` for project push settings. +- Current implementation choice: + - use the existing project-settings DB pattern for push config + - keep push-device / event / delivery transactional flows on direct Diesel connections so enqueue and delivery leasing can stay atomic + +## 2026-03-07 — Section 2 complete: routes, config plumbing, and worker skeleton + +- Added platform push settings API under: + - `GET /platform/orgs/:org_id/projects/:project_id/settings/push` + - `PUT /platform/orgs/:org_id/projects/:project_id/settings/push` +- Added app-user push device API under: + - `POST /v1/push/devices` + - `GET /v1/push/devices` + - `DELETE /v1/push/devices/:id` +- Added raw project secret helpers in `AppState` so push senders can consume PEM / JSON secrets without the old base64 wrapper. +- Added push module structure: + - `src/push/mod.rs` + - `src/push/crypto.rs` + - `src/push/apns.rs` + - `src/push/fcm.rs` + - `src/push/worker.rs` +- Added: + - per-device P-256 ECDH encrypted preview envelope generation + - APNs token-auth caching + send path + - FCM OAuth token caching + HTTP v1 send path + - leased delivery worker loop with retry / invalid-token handling +- Wired the push worker into server startup. + +## 2026-03-07 — Section 3 complete: Maple SSE disconnect behavior + +- Refactored `src/web/agent/mod.rs` so agent generation now runs in a background task behind an internal channel instead of inside the SSE stream body. +- New behavior: + - if the SSE channel is still alive, agent message events are streamed and push is suppressed + - if the SSE channel is dropped, generation continues and the backend enqueues exactly one notification for that interrupted turn + - if multiple assistant messages were generated after disconnect, the first missed assistant message is used as the preview source for that one notification +- Stream success detection was tightened beyond simple in-process queueing: + - the background task now waits for a stream-side delivery acknowledgement before treating an assistant message event as delivered +- Push enqueue helper currently wires agent-originated messages into `notification_events` + `notification_deliveries` with a stable `notification_id` and generic provider-visible fallback text. + +## 2026-03-08 — Review fixups in progress + +- Updated encrypted preview envelope serialization to match the main spec (`enc_v`, `p256-hkdf-sha256-aes256gcm`). +- Added conservative preview-body truncation so encrypted iOS previews stay within a small payload budget. +- Tightened worker failure handling so pre-send errors now transition deliveries to `retry` or `failed` instead of leaving rows stuck in leased retry loops. +- Tightened push registration: + - validate registered public keys as P-256 SPKI + - allow account-switch / stale-token recovery by moving active ownership to the latest installation registration + - stop revoked rows from permanently occupying the global token uniqueness slot +- Tightened APNs invalidation behavior so topic/config mistakes do not auto-revoke valid devices. + +## 2026-03-07 — Section 4 complete: enclave networking glue in-repo + +- Updated `entrypoint.sh` with local host mappings for: + - `api.push.apple.com` + - `api.sandbox.push.apple.com` + - `fcm.googleapis.com` +- Added enclave traffic forwarders for: + - APNs prod (`8024`) + - APNs sandbox (`8025`) + - FCM (`8029`) +- Important remaining deployment note: + - the parent-instance `vsock-proxy` allowlist and systemd unit changes described in the design doc are not represented as editable runtime config files in this repo, so those still need to be applied in deployment infrastructure outside this codebase. + +## 2026-03-07 — Validation + +- Ran `cargo fmt --all` +- Ran `cargo clippy --all-targets --all-features -- -D warnings` +- Ran `cargo test` +- Result: all validators passed (`104` tests) +- Re-ran `cargo clippy --all-targets --all-features -- -D warnings && cargo test` after the enclave networking script updates; still green. diff --git a/internal_docs/scheduled-agent-wakeups.md b/internal_docs/scheduled-agent-wakeups.md new file mode 100644 index 00000000..4d7df989 --- /dev/null +++ b/internal_docs/scheduled-agent-wakeups.md @@ -0,0 +1,1725 @@ +# Scheduled Agent Wakeups and Background Turns + +## Status + +- **Date:** March 2026 +- **Status:** v1 design reviewed; initial implementation landed +- **Audience:** backend / agent / mobile product design +- **Purpose:** capture the agreed architecture for scheduled agent wakeups and record the initial implementation status + +This document started as a forward-looking design draft. It now also records the initial implementation that landed on 2026-03-23. + +Historical research notes are still preserved below. Any "open questions" inside the section 17 research log should be read as point-in-time notes from before implementation unless they are restated later as still-open items. + +## Implementation update (2026-03-23) + +The agreed v1 scheduler shape is now wired into the codebase. + +Implemented pieces: + +- Postgres-backed schedule definitions plus per-occurrence run records via `agent_schedules` and `agent_schedule_runs` +- Structured recurrence for `one_off`, `interval`, `daily`, and `weekly` schedules +- Timezone mode support for `follow_user` versus `fixed`, including recomputation when the user's timezone preference changes +- Agent tools for `schedule_task`, `list_schedules`, and `cancel_schedule` +- Background schedule worker startup in `main.rs` +- Lease-based execution with retries, heartbeat renewal, stale-expiry handling, and partial-output completion behavior +- Scheduled turns reusing the normal agent runtime for tool calls and assistant-message persistence, without inserting fake user messages +- Scheduled reminder push enqueueing with one encrypted preview per turn, using `llama-3.3-70b` summarization plus first-message fallback +- Coverage for recurrence parsing / calculation edge cases including DST-gap handling + +Still intentionally out of scope for v1: + +- direct client schedule-management APIs beyond the agent tools +- strict per-agent turn serialization beyond the accepted v1 parallel-turn tradeoff +- advanced calendar rules like third Monday / last business day + +--- + +## 1. Core Product Idea + +The key design decision is: + +**scheduled tasks should wake the agent, not directly send notifications.** + +That means a scheduled event is not fundamentally a push job. It is a future agent-triggered turn. + +The LLM remains the brain: + +- the agent decides whether to message the user at all +- the agent decides what tone and wording to use +- the agent may use normal tools before replying +- the agent may decide the event is no longer relevant and do nothing + +Push delivery is a downstream consequence of that background turn, not the primary product abstraction. + +This is the main behavioral difference from a traditional reminder system. We are not storing “notification text to send later.” We are storing “instructions for the future agent to consider when that time arrives.” + +--- + +## 2. Example Product Behavior + +### 2.1 Wake-up example + +User says: + +> I want a wake up notification at 8am tomorrow. + +Agent says: + +> Okay. + +The agent schedules a one-off event for the next day at 8am local time. The scheduled payload is **not** the notification text. It is a future-facing instruction for the agent, such as: + +> At 8am tomorrow, wake the user up. Check whether they have already been active in the conversation this morning before sending anything. If there is relevant recent context about why they need to wake up, use it. + +At 8am, the event fires and wakes the agent in the background. + +The agent then has normal context available: + +- recent thread history +- memory blocks +- archival memory +- conversation search +- normal tools +- current local time / timezone context + +At that point the agent might: + +- send nothing because the user already woke up and has been chatting since 7am +- send a personalized wake-up message based on newer context +- check the weather first, then send a more useful message +- send several messages if that is what the normal turn logic decides + +### 2.2 Weather-aware wake-up example + +User says: + +> Wake me up at 8 tomorrow and let me know if I need a coat. + +At 8am, the agent wakes, sees the scheduled instruction, calls `web_search`, gets the weather, and then sends: + +> Hey, it’s 30 degrees out. Wake up and put on a coat. + +This is exactly why the scheduled event should enter the normal agent runtime rather than bypassing it with prewritten push text. + +--- + +## 3. Primary v1 Decisions + +### 3.1 Best-effort agent wakeup + +The system is **best-effort**, not alarm-clock-grade guaranteed delivery. + +If the model stack is down or a dependency fails, the system should retry according to durable queue semantics. But if the full turn never succeeds, we accept that no user-visible notification may be delivered. + +This is consistent with the product philosophy: + +- the LLM is the decision-maker +- the scheduled event exists to wake the agent +- there is no separate deterministic fallback reminder engine in v1 + +### 3.2 Internal event, not fake user or tool-result persistence + +When a scheduled event fires, it should enter the runtime as a **new internal event type**. + +It should **not** be persisted as: + +- a fake `user_message` +- a fake `assistant_message` +- a fake `tool_output` +- a synthetic row in existing message tables pretending a tool call stayed open for hours + +The internal event should still reuse almost all of the normal runtime behavior, but it is not itself normal chat history. + +### 3.3 Reuse the normal agent runtime as much as possible + +The scheduled-event turn should reuse the same major machinery as a normal user-driven agent turn: + +- thread loading +- memory loading +- prompt assembly +- tool registry +- tool execution chain +- assistant message persistence +- post-turn delivery / push planning + +In other words, the internal trigger is new, but nearly all of the downstream execution model should stay aligned with the existing agent flow. + +### 3.4 Normal outputs are persisted normally + +The **results** of the scheduled turn should be treated as ordinary assistant behavior. + +If the agent sends messages, those messages should be stored as real assistant messages. + +If the agent calls tools, those tool calls and tool outputs should follow the normal persistence rules used by the existing runtime. + +If the agent sends three messages, then calls tools, then sends three more messages, that is allowed. The scheduled trigger should not artificially force a single-message reminder format. + +### 3.5 No explicit push tool + +There should be no “send push notification” tool in this design. + +The agent should keep doing normal things: + +- send messages +- search +- consult memory +- reason over recent context + +If the scheduled background turn produces assistant messages and there is no live SSE consumer for that turn, then the delivery system should treat those outputs as eligible for push, subject to the user’s push settings. + +Push remains delivery plumbing, not agent-facing product logic. + +### 3.6 One push per scheduled turn by default + +For v1, scheduled background turns should usually produce **at most one push notification event**. + +Even if the agent emits multiple assistant messages during the turn, we should derive a single encrypted preview summary for notification delivery. + +This keeps scheduled events from producing noisy notification bursts while still preserving the full assistant output in conversation history. + +### 3.7 No separate notifier agent + +We should **not** create a second autonomous “notifier” agent. + +Instead, after the main scheduled turn completes, we should run a lightweight **notification-preview composer** over the turn’s assistant outputs. + +That post-processing step is allowed to use a cheap model, but it is not another reasoning agent with independent product behavior. + +--- + +## 4. Scheduling Model + +### 4.1 Agent-scoped future wakeups + +Scheduled events should belong to an agent identity, not to a generic global notification system. + +That means the event targets: + +- the main agent, or +- a specific subagent + +When the event fires, that exact agent/thread is what wakes up. + +### 4.2 Store instructions for the future agent + +The stored payload should be agent-oriented instructions, not notification copy. + +Good examples: + +- “At 8am tomorrow, wake the user up for yoga class. If they have already been active this morning, avoid a redundant wake-up message. If weather seems relevant, check it first.” +- “Tomorrow at 6pm, remind the user to leave for dinner. If recent conversation changed the plan, adapt to the latest context.” + +Bad examples: + +- “Wake up! Time for yoga!” +- “It’s cold outside, bring a coat!” +- “Reminder: leave now.” + +Those bad examples are candidate notification text, not the right stored instruction shape. + +### 4.3 One-off first, with limited recurring support in v1 + +The primary implementation milestone should still be **one-off scheduled events first**. + +However, based on current product discussion, the broader v1 design should also be able to support a **limited structured recurrence model** for common cases like: + +- every hour +- every morning at 8am +- every Friday at 5pm +- weekdays at 7:30am + +What remains out of scope is not recurrence altogether, but rather: + +- raw free-form cron syntax as the main product surface +- advanced calendar-style recurrences like “third Monday” or “last business day” + +### 4.4 Schedule time is authoritative; message wording is not + +The scheduler is responsible for firing the event at the right time. + +The agent is responsible for deciding whether and how to turn that event into user-visible output. + +This division of responsibility is important: + +- time semantics are deterministic infrastructure +- language semantics stay inside the agent runtime + +--- + +## 5. Tool Contract for Scheduling + +### 5.1 Agent-facing scheduling tool goal + +The scheduling tool should explicitly teach the model that it is writing instructions for its **future self**. + +The tool prompt / schema guidance should say that the agent must: + +- write in normal human language +- describe the goal and relevant context for the future turn +- mention conditional behavior when appropriate +- avoid drafting final notification copy +- avoid assuming the future state will match the current one exactly + +### 5.2 Recommended instruction-writing guidance + +The tool contract should push the agent toward guidance like this: + +> You are scheduling a future wakeup for yourself. Write instructions for the future agent turn, not the final notification text. Be specific about the goal, timing, relevant context, and any useful conditional behavior. Use normal natural language. Do not optimize for brevity if detail would help the future agent make a better decision. + +### 5.3 Suggested base scheduling arguments + +The exact schema can be finalized later, but the base shape is roughly: + +- `run_at`: absolute scheduled time +- `timezone`: optional user-local timezone override if needed +- `instruction`: detailed future-agent instruction in natural language +- `description`: short operational description for listing/debugging +- `expires_at`: optional staleness cutoff for cases where old reminders stop being useful + +The important field is `instruction`, and it should be modeled as the sensitive core payload. + +Recurring-specific structured fields are described later in section `19.15`. + +### 5.4 Example of a good scheduled instruction + +> At 8am local time tomorrow, wake the user up. They want this as a real wake-up nudge, not just a passive reminder. Before sending anything, consider whether they have already been active in chat this morning. If there is recent context about what they need to wake up for, use that context. If helpful, you may check weather or other relevant information before replying. + +This is the right shape because it tells the future agent what job it is trying to do without locking it into exact wording. + +--- + +## 6. Runtime Trigger Model + +### 6.1 Scheduled event wakeup path + +When a scheduled event becomes due: + +1. a worker leases the event row +2. the worker claims execution responsibility for that target agent/thread on that server +3. the runtime starts a background agent turn using a scheduled-event trigger +4. the runtime injects hidden event context +5. the agent executes its normal loop +6. normal outputs are persisted +7. post-turn delivery planning decides whether to enqueue push +8. the scheduled event row is completed or retried + +### 6.2 Hidden event context + +The runtime should inject hidden context that includes things like: + +- scheduled event id +- scheduled-for time +- actual fired-at time +- local timezone / local wall-clock representation +- the stored instruction +- optional source message reference or creation context + +This hidden context should behave similarly to other runtime-only inputs, but it should not create user-visible history rows by itself. + +### 6.3 Prompt shape + +The scheduled event should enter prompt assembly in a form that clearly communicates: + +- this is an internal scheduled wakeup +- the user did not just send a new live message right now +- the agent should consider the latest context before acting +- it may decide to no-op + +The system should make it easy for the agent to understand that the stored instruction came from an earlier planning moment and may need reinterpretation based on newer context. + +--- + +## 7. Delivery and Push Behavior + +### 7.1 Push is downstream of the background turn + +The scheduled event itself should not enqueue push directly. + +Instead: + +- the agent finishes its background turn +- the system inspects the assistant outputs from that turn +- if there are user-visible assistant messages and push is enabled, a notification event may be enqueued + +### 7.2 Notification preview generation + +For the encrypted notification preview text: + +- use the existing available **Llama** model as a compression/summarization step +- its job is to compress the turn’s assistant outputs into a short preview while preserving tone and intent +- it must not invent facts not present in the assistant outputs + +The summarizer should receive the assistant outputs from that scheduled turn and produce a compact 1-2 sentence preview suitable for encrypted notification display. + +### 7.3 Preview fallback behavior + +If the Llama summarizer fails, fall back to: + +- the **first assistant message** produced in that scheduled turn + +This fallback does not need to inspect which substep or tool boundary produced it. If it is the first normal assistant message from the turn, it is the fallback preview source. + +### 7.4 Preserve full assistant output in chat history + +The push preview is only a delivery summary. + +The full assistant outputs of the scheduled turn should still be persisted normally. The notification preview should not replace or reshape the underlying conversation history. + +### 7.5 One encrypted preview per scheduled turn + +If the agent produced multiple messages, the summarizer should compress across the whole turn and yield one preview. + +That preview becomes the encrypted notification text. The provider-visible shell can remain generic, consistent with the current encrypted push model. + +--- + +## 8. Retry, Leasing, and Recovery + +### 8.1 Durable queue semantics + +Scheduled events need real durable worker semantics, not an in-memory timer. + +At minimum this requires: + +- due-time polling +- row leasing +- lease expiry recovery +- explicit retry states +- backoff +- attempt counters +- terminal failure handling + +### 8.2 Use both lease expiry and explicit retry + +We want both failure mechanisms: + +1. **lease expiry** for crash recovery +2. **retry status with next attempt time** for known transient failures + +That means: + +- if a worker crashes mid-turn, another worker can recover the event after lease expiry +- if the runtime returns a transient failure, the current worker should explicitly schedule a retry with backoff + +### 8.3 Extend or heartbeat the lease while the turn runs + +Scheduled turns may take longer than a small lease interval, especially if they involve tool calls. + +The worker should therefore renew the lease while the turn is active so another worker does not mistakenly steal the same event during a long-running but healthy execution. + +### 8.4 Per-agent turn serialization + +The long-term safest model is still: + +- every agent has one serialized turn lane +- scheduled turns join that same lane +- user turns still take precedence where appropriate, but concurrency is controlled centrally + +This avoids context corruption, duplicate outputs, and confusing interleaving. + +However, the current v1 decision is more relaxed: + +- a scheduled wakeup may run with its own lifetime even if a live turn for that same agent is already in progress + +So serialization remains the preferred end-state, but it is **not** a blocker for the first implementation pass. + +### 8.5 Staleness and expiry + +Many scheduled events are time-sensitive. + +Examples: + +- a wake-up reminder is useful at 8:00am, much less useful at 2:00pm +- a “leave now for dinner” reminder may be stale shortly after it misses its target + +So scheduled events should support an explicit expiry or stale-after threshold. Once stale, the system should stop retrying and mark the event terminal. + +### 8.6 Best-effort failure model + +Because this feature is explicitly best-effort, there is no requirement in v1 to synthesize a system-generated reminder if the LLM never completes successfully. + +If all retries fail or the event expires, the event simply fails. + +--- + +## 9. Data Model Intent + +### 9.1 Separate scheduled-events table + +This feature should use a dedicated scheduled-events table rather than overloading `notification_events`. + +`notification_events` should remain the push outbox. + +The new scheduled-events table is the source of truth for: + +- when to wake the agent +- which agent to wake +- what instruction to inject +- current lease / retry / completion state + +### 9.2 Plaintext fields + +We should keep operational fields plaintext so PostgreSQL can index, filter, and lease them efficiently. + +That likely includes: + +- row id / uuid +- user id +- agent id +- conversation id or agent-thread reference if needed +- status +- run time +- next attempt time +- lease owner / lease expiry +- attempt count +- created at / updated at +- optional expiry time +- short operational description +- source message id references where useful + +These are infrastructure fields, not the sensitive semantic payload. + +### 9.3 Encrypted fields + +The main sensitive field is likely: + +- `instruction_enc` + +That is the actual future-agent instruction and is the most obvious content that should be encrypted at rest. + +We may also want an optional encrypted metadata blob for future-sensitive context if we later decide we need more than just the instruction text. + +### 9.4 Plaintext minimum principle + +We should keep only the minimum necessary fields encrypted. + +The goal is: + +- preserve queryability, scheduling efficiency, and operational simplicity in Postgres +- encrypt only the sensitive content-bearing parts of the event + +At the current intent level, that likely means **the instruction is the primary encrypted field**, while scheduling and operational metadata stay plaintext. + +### 9.5 Error storage should be sanitized + +Retry / failure diagnostics should avoid storing highly sensitive prompt or model output content in plaintext error columns. + +Plaintext `last_error` fields should stay operational and sanitized. + +--- + +## 10. Execution Semantics + +### 10.1 The scheduled event itself is not chat history + +The scheduled trigger should not appear in user-visible history as if the user just sent a new message. + +The user should see only the outputs the agent actually chose to send. + +### 10.2 The scheduled event can still lead to rich behavior + +Even though the trigger itself is hidden, the turn can still produce: + +- multiple assistant messages +- tool calls +- tool outputs +- follow-up reasoning using the normal runtime + +This is intentional. Scheduled turns should not be artificially crippled compared with ordinary turns. + +### 10.3 No-op is valid behavior + +The agent should be allowed to conclude that no user-visible action is needed. + +Examples: + +- the user is already awake and active +- the plan changed in later conversation +- the event is now irrelevant given newer context + +In those cases the event should complete successfully with no assistant message and no push. + +--- + +## 11. Notification Preview Composer + +### 11.1 Purpose + +The notification-preview composer exists only to create a compact encrypted push preview after a scheduled turn. + +It is not a new product-level agent. + +### 11.2 Inputs + +The composer should consume: + +- the assistant messages produced by the scheduled turn +- optional turn metadata like thread target or message ids + +It should not independently search, reason, or reinterpret the user’s world beyond compressing the already-produced assistant output. + +### 11.3 Model choice + +Use the existing available **Llama** model because it is already present and is a strong fit for compression / summarization. + +### 11.4 Behavior requirements + +The preview composer should: + +- preserve tone and intent +- stay faithful to the assistant outputs +- avoid adding new facts +- keep the result short +- target notification-preview length rather than full chat prose + +### 11.5 Deterministic fallback + +If the composer fails for any reason, the fallback is deterministic: + +- use the first assistant message from the turn as the encrypted preview text + +This keeps the delivery path robust without adding another complex branch. + +--- + +## 12. Security and Privacy Boundaries + +### 12.1 Sensitive data should remain encrypted + +The instruction payload is sensitive because it contains semantic user intent and planning context. + +That content should be encrypted at rest in the scheduled-events table. + +### 12.2 Operational metadata may remain plaintext + +We do not need to encrypt every field just because it belongs to a scheduled event. + +In particular, fields required for: + +- polling +- leasing +- retry management +- indexing +- expiry checks +- list views + +should remain plaintext unless they themselves carry sensitive user content. + +### 12.3 No plaintext instruction logging + +The system should avoid logging: + +- decrypted scheduled instructions +- raw notification preview plaintext +- full assistant turn content in operational logs + +Logs should stay operational rather than content-rich. + +### 12.4 Push encryption remains unchanged in principle + +Scheduled turns should continue to use the same encrypted-push architecture already adopted for mobile push delivery. + +The only new part is how the preview text is sourced: + +- from the assistant outputs of the scheduled turn, +- compressed by Llama, +- with first-message fallback. + +--- + +## 13. Product Scope for the First Implementation Pass + +### In scope + +- one-off scheduled agent wakeups +- limited structured recurring schedules for common cases (interval / daily / weekly) +- agent-scoped scheduled events for main agent and subagents +- encrypted-at-rest scheduled instructions +- durable leasing and retries +- hidden internal event trigger into the normal agent runtime +- normal persistence of assistant outputs +- one encrypted push preview derived after turn completion +- Llama preview compression with first-message fallback +- no-op outcomes when the agent decides nothing should be sent + +### Out of scope for the first pass + +- raw cron as the primary product/API surface +- advanced calendar-style recurrences (e.g. third Monday, last business day) +- a separate notifier agent +- a user-facing generic push tool +- persisting the internal scheduled trigger into chat history +- guaranteed alarm-clock delivery semantics +- a deterministic system-generated reminder fallback when the LLM never succeeds + +--- + +## 14. Main Rejected Alternatives + +### 14.1 Scheduled events directly enqueue notifications + +Rejected because it prevents the agent from adapting to newer context and reduces reminders to prewritten notification copy. + +### 14.2 Persist the fired event as a fake user message + +Rejected because it pollutes chat history and misrepresents what actually happened. + +### 14.3 Persist the fired event as a delayed tool result + +Rejected because it is structurally misleading and creates awkward long-lived pseudo-tool state. + +### 14.4 Second autonomous notifier agent + +Rejected because it adds a second reasoning layer when a lightweight preview composer is enough. + +### 14.5 Explicit push-notification tool + +Rejected because push should remain a delivery concern, not a first-class agent product surface. + +--- + +## 15. Open Questions for the Next Pass + +These are intentionally left for a later code-aware design pass: + +- exact table schema and naming +- exact worker / lease heartbeat implementation +- exact agent-turn locking strategy relative to live user turns +- whether `conversation_id` should be stored on the scheduled row or derived from `agent_id` +- how user-visible schedule listing / cancellation UX should work +- how much scheduling metadata should be exposed to clients +- whether multiple due scheduled events for one agent should coalesce before execution +- whether future versions should support richer recurrence families or additional event classes beyond the initial interval/daily/weekly set + +Note: the default stale / expiry policy for v1 is now set to **15 minutes**; what remains open is whether some schedule families should override that by default. + +--- + +## 16. Bottom Line + +The intended architecture is: + +**schedule now -> wake the agent later -> let the agent decide what to do -> optionally push the result** + +That preserves the core Maple product idea: + +- the LLM is the brain +- reminders are context-aware agent behaviors, not canned notifications +- push is a downstream transport layer +- scheduled instructions are written for the future agent, not for the lock screen + +This should be the starting point for the implementation design pass. + +--- + +## 17. Code Research Log + +This section is intentionally incremental. It captures code-informed findings as they are discovered, rather than waiting for one final summary. + +### 17.1 Research pass 1 — current live agent turn flow and push hook + +Relevant files reviewed in this pass: + +- `src/web/agent/mod.rs` +- `src/web/agent/runtime.rs` +- `src/push/mod.rs` +- `src/models/notification_events.rs` +- `src/models/notification_deliveries.rs` +- `src/push/worker.rs` + +#### What the code does today + +The current `agent.message` push path is tightly coupled to the live SSE chat flow. + +In `src/web/agent/mod.rs`, `run_agent_chat_task(...)` currently does this: + +1. initialize `AgentRuntime` +2. call `runtime.prepare(&input_content)` +3. run `runtime.step(...)` in a loop +4. persist any tool call + tool output rows +5. persist assistant messages with `runtime.insert_assistant_message(...)` +6. attempt to stream each assistant message over SSE +7. if SSE delivery fails, remember only the **first persisted-but-undelivered** assistant message +8. after the turn completes, enqueue **one** push notification from that first missed assistant message + +This means the current implementation already has the important product behavior of: + +- persisting assistant output first +- treating push as downstream delivery +- sending at most one push per interrupted turn + +That is directionally aligned with the scheduled-wakeup design. + +#### Important code-level constraints discovered + +##### `prepare()` currently persists a user message + +`AgentRuntime::prepare(...)` in `src/web/agent/runtime.rs` currently inserts a real `user_messages` row and schedules embedding work. + +That is correct for live user chat, but it is **not** correct for scheduled wakeups if the internal scheduled trigger must stay hidden. + +This strongly suggests the scheduled-turn implementation will need either: + +- a parallel `prepare_internal_event(...)` path, or +- a refactor that separates “build step input” from “persist user message.” + +##### Assistant/tool persistence is already reusable + +`AgentRuntime` already exposes reusable persistence helpers for the outputs we do want: + +- `insert_assistant_message(...)` +- `insert_tool_call_and_output(...)` + +Those helpers also already spawn background embedding work for assistant messages, which is good: scheduled turns should benefit from the same memory/indexing behavior as ordinary turns. + +##### Current push enqueue expects one `message_id` and one text body + +`enqueue_agent_push_for_disconnect(...)` calls `enqueue_agent_message_notification(...)`, which takes: + +- a single `message_id` +- a single `message_text` + +That is sufficient for the current disconnect flow, but it is narrower than the scheduled-turn design, where one push preview may need to summarize **multiple** assistant messages from one background turn. + +##### The push outbox is already durable and leased + +The push system already has a strong distributed-worker pattern: + +- durable `notification_events` +- per-device `notification_deliveries` +- leasing via `FOR UPDATE SKIP LOCKED` +- explicit retry transitions +- lease ownership checks on writeback +- expiry/cancellation handling + +This is a strong conceptual template for scheduled-event execution, even though the scheduled-event table and worker do not exist yet. + +#### Early implications for the feature + +- scheduled wakeups should likely reuse the existing assistant/tool persistence paths +- scheduled wakeups should **not** reuse the live `prepare(...)` path unchanged +- push for scheduled turns should likely happen **after the whole background turn finishes**, not on the first produced assistant message +- the current push helper shape probably needs either a lower-level enqueue path or a new scheduled-turn-specific wrapper + +#### Open questions discovered in this pass + +- What is the cleanest hidden-trigger entrypoint into `AgentRuntime`? +- Should scheduled turns share most of `run_agent_chat_task(...)` through an extracted non-SSE turn runner? +- What should `message_id` mean when one push preview summarizes several assistant messages from one turn? +- How do we make one-push-per-turn idempotent across scheduled-worker retries? +- I did **not** find an obvious existing per-agent turn lock in the reviewed code, so where should scheduled-turn serialization live relative to live user turns? + +### 17.2 Research pass 2 — context assembly, model hooks, and concurrency gaps + +Relevant files reviewed in this pass: + +- `src/web/agent/runtime.rs` +- `src/web/agent/signatures.rs` +- `src/web/agent/compaction.rs` +- `src/proxy_config.rs` +- `src/web/openai.rs` +- `src/web/responses/handlers.rs` +- `src/main.rs` + +#### What the runtime already gives us for a scheduled turn + +`AgentRuntime::build_context(...)` already assembles most of the context we want a scheduled wakeup to inherit. + +It currently loads: + +- the user’s timezone from `user_preferences` +- the user’s locale from `user_preferences` +- a formatted `current_time` string localized to that timezone +- decrypted persona + human memory blocks +- memory metadata counts +- the latest conversation summary +- recent conversation history +- main-agent onboarding state +- subagent purpose + +This is important because it means a scheduled wakeup does **not** need a special prompt stack to feel “normal.” Once it reaches the runtime correctly, it can already inherit most of the same state as a live user turn. + +#### Tool-result continuation behavior is already encoded in the runtime + +`AgentRuntime::step(...)` already has a continuation mode for follow-up steps after tools run. + +It tracks: + +- `current_tool_results` +- `previous_step_summary` + +and then injects tool-result instructions like: + +- this is a continuation of the previous turn +- previous messages are already visible to the user +- silence is the default unless there is genuinely new information + +This does **not** directly implement scheduled events, but it shows the runtime already understands “non-fresh-user-message continuation” as a first-class pattern. + +That is encouraging for the scheduled-event design, which also wants a hidden internal trigger rather than a fake new user message. + +#### `prepare()` currently does more than validation + +This pass confirmed that `prepare(...)` currently does all of the following together: + +- validates / normalizes the user message +- runs vision pre-processing when needed +- clears tool-result state +- inserts the user message +- triggers background embedding +- runs compaction checks + +This means a hidden scheduled-event entrypoint will need a deliberate decision about which of those behaviors to keep. + +Most likely: + +- keep tool-result reset +- probably keep compaction behavior +- skip user-message insertion +- skip any semantics that assume a new live user-authored message exists + +#### LLM helper infrastructure is already flexible enough for a preview composer + +There are two useful existing LLM helper patterns in the codebase. + +##### Pattern A: direct small non-streaming helper calls + +`src/web/responses/handlers.rs::spawn_title_generation_task(...)` already runs a background helper call using: + +- model: `llama-3.3-70b` +- `get_chat_completion_response(...)` +- non-streaming completion extraction +- best-effort logging-only failure behavior + +This is a strong template for a post-turn preview-composition step. + +##### Pattern B: typed DSRS summarization with correction + +`src/web/agent/compaction.rs` already implements typed summarization with correction retries. + +That path uses: + +- `build_lm(...)` from `src/web/agent/signatures.rs` +- typed signatures +- malformed-output correction + +This means we already have a reusable pattern if we decide the notification-preview composer should be typed rather than raw string extraction. + +#### Llama availability is real, but conditional + +`src/proxy_config.rs` confirms that `llama-3.3-70b` is a routed canonical model in this repo. + +However, it is only available when `TINFOIL_API_BASE` is configured. Without Tinfoil, the router falls back to Continuum-only models and Llama is not included. + +That means the current product decision of “use Llama for preview composition” is viable, but there is an implementation question about what to do in environments where Tinfoil is absent. + +#### Current agent model defaults + +The main agent runtime currently builds its LM with: + +- request model: `kimi-k2-5-agent` +- billing model: `kimi-k2-5` + +So the preview composer would be a genuine secondary helper model path, not just a reuse of the main-agent LM. + +#### I still did not find an agent turn lock + +I searched the agent runtime and broader app state for agent/conversation turn serialization and did **not** find an obvious existing lock, semaphore, or in-flight turn registry for agent chat. + +This remains one of the most important implementation gaps discovered so far. + +#### Additional structural finding for subagents + +`AgentRuntime::new_subagent(...)` already reconstructs the runtime from the subagent UUID by loading: + +- the subagent row +- its conversation +- its decrypted purpose + +That means scheduled events targeting subagents can likely anchor on subagent identity cleanly without needing to persist every prompt-level field on the scheduled row. + +#### Open questions discovered in this pass + +- Should the scheduled-event row store `agent_id`, `agent_uuid`, `conversation_id`, or some combination? +- Should the scheduled-event entrypoint run compaction before the turn, the same way live `prepare(...)` does? +- Should the preview composer use the lightweight raw-completion pattern like title generation, or a typed DSRS summarizer with correction? +- What should happen in environments where `llama-3.3-70b` is unavailable because Tinfoil is not configured? +- Where should agent-turn serialization live if the current runtime has no obvious lock/queue for it? + +### 17.3 Research pass 3 — agent signature shape and queue-schema implications + +Relevant files reviewed in this pass: + +- `src/web/agent/signatures.rs` +- `src/web/agent/runtime.rs` +- `migrations/2026-03-07-120000_push_notifications_v1/up.sql` + +#### Current agent signature shape is input-centric + +The main agent signature in `src/web/agent/signatures.rs` currently takes these top-level inputs: + +- `input` +- `current_time` +- `agent_kind` +- `subagent_purpose` +- `persona_block` +- `human_block` +- `memory_metadata` +- `previous_context_summary` +- `recent_conversation` +- `available_tools` +- `is_first_time_user` + +There is currently **no explicit field for trigger kind** such as: + +- live user message +- tool-result continuation +- scheduled internal event + +So if we want scheduled events to be a first-class trigger type at the prompt layer, we will likely need one of two approaches: + +1. encode the scheduled-event semantics entirely inside the `input` string, or +2. extend the agent signature with an explicit field such as `turn_source`, `event_kind`, or equivalent + +This is an important design choice because it affects how visible and stable the scheduled-event concept becomes in prompt assembly. + +#### The runtime already supports first-step raw string input + +`AgentRuntime::step(...)` accepts a plain `user_message: &str` for the first step after `prepare(...)` returns the normalized text. + +That means a scheduled internal event could probably enter the runtime as a first-step string without needing to fake a `MessageContent` object. + +So there is already a plausible implementation path where the scheduled-event worker: + +- skips `prepare(...)` +- builds a hidden event input string +- feeds that directly into `step(..., is_first_step = true)` + +Whether that is the final shape depends on how much explicit structure we want in the signature. + +#### Retry and correction behavior already exists for agent outputs + +`call_agent_response_with_retry_and_correction(...)` in `src/web/agent/signatures.rs` already provides: + +- up to 3 LM attempts +- correction on parse failures +- a fallback correction prompt that reshapes malformed outputs rather than generating new content + +This means scheduled turns would inherit the same output-hardening behavior as live turns if they reuse the same signature path. + +That is useful for the best-effort design: the scheduled worker should not need a separate output-repair mechanism if it is truly using the same runtime. + +#### Push schema confirms a separation we should preserve + +The push migration confirms that `notification_events` is structurally a delivery/outbox table, not a scheduler table. + +Notable fields: + +- `payload_enc` +- `not_before_at` +- `expires_at` +- `cancelled_at` +- one delivery row per device with lease/retry state + +This reinforces the design decision already captured earlier: + +- `notification_events` should remain the push outbox +- scheduled-agent wakeups should have their own source-of-truth table + +Even though `notification_events.not_before_at` exists, it is the wrong abstraction for “wake the agent and let it decide what to do.” + +#### New open questions from this pass + +- Is it better to add an explicit `event_kind` / `turn_source` field to the agent signature now, or keep scheduled-event semantics hidden in the first-step input text? +- If scheduled turns bypass `prepare(...)`, what is the cleanest place to run the subset of prepare-time behavior we still want, like compaction and tool-state reset? +- Should the scheduled-event worker persist a turn-level execution record for idempotency / observability, even if the trigger itself stays out of chat history? + +### 17.4 Research pass 4 — preview payload and delivery-shape details + +Relevant files reviewed in this pass: + +- `src/push/mod.rs` +- `src/web/agent/mod.rs` + +#### The current encrypted preview payload is message-centric + +`NotificationPreviewPayload` currently contains: + +- `notification_id` +- `message_id` +- `kind` +- `title` +- `body` +- `deep_link` +- `thread_id` +- `sent_at` + +This is important for scheduled turns because the current payload model assumes a notification is anchored to **one message id**. + +That works naturally for the current disconnect flow, where the preview is derived from one first-undelivered assistant message. + +It is a bit more awkward for scheduled turns, where the preview may summarize an entire background turn. + +#### Current preview text budget is very small + +`src/push/mod.rs` sets: + +- `PUSH_PREVIEW_BODY_MAX_BYTES = 180` + +and `normalize_preview_body(...)` currently: + +- collapses whitespace +- truncates to that byte budget +- adds an ellipsis when needed + +This means the Llama preview composer should target a genuinely short result. Even if we ask for 1-2 sentences, the implementation still needs to respect this small body budget. + +#### Deep-linking already distinguishes main vs subagent + +The current push helper already derives: + +- main agent deep link: `opensecret://agent` +- subagent deep link: `opensecret://agent/subagent/` + +and matching `thread_id` values. + +That is useful because a scheduled-turn push can likely reuse the same targeting rules without inventing a new notification routing system. + +#### Current scheduled-turn preview implication + +For scheduled turns, if we keep the existing preview payload shape, then we probably need to choose a canonical message id for the turn, most likely: + +- the first assistant message of the scheduled turn, or +- the last assistant message of the scheduled turn + +The first-message option aligns better with the already-planned fallback behavior and with the current disconnect path, which also anchors on the first user-visible missed message. + +#### Additional open questions from this pass + +- Should scheduled-turn notifications keep the existing message-centric payload shape and simply use the first assistant message as the canonical anchor? +- Do we want a future turn-level identifier in preview payloads, or is that unnecessary if one notification maps to one scheduled turn? +- Should the preview composer enforce a tighter output limit than “1-2 sentences” so it naturally fits the 180-byte cap? + +### 17.5 Research pass 5 — concrete worker numbers and missing lease-heartbeat support + +Relevant files reviewed in this pass: + +- `src/push/worker.rs` +- `src/models/notification_deliveries.rs` + +#### Current push worker defaults + +The existing push worker uses these concrete values: + +- batch size: `32` +- lease TTL: `60` seconds +- poll interval: `3` seconds +- max concurrency: `8` +- max attempts: `8` + +Retry backoff is exponential and currently caps at `480` seconds: + +- attempt 1 -> `15s` +- attempt 2 -> `30s` +- attempt 3 -> `60s` +- attempt 4 -> `120s` +- attempt 5 -> `240s` +- attempt 6+ -> `480s` + +These values are useful reference points for scheduled-event execution, but scheduled agent turns will probably need longer-running lease behavior than push deliveries do. + +#### Important limitation: the existing worker does not heartbeat or extend leases + +The push worker leases a delivery row once and then processes it. There is no lease-renewal path because provider send attempts are expected to be short. + +That is likely **not** enough for scheduled agent turns, which may: + +- call tools +- wait on network operations +- run multiple LLM steps +- take meaningfully longer than a push send + +So the scheduled-event worker probably needs one of these patterns: + +- an explicit lease heartbeat / renewal update, or +- a substantially longer lease window plus separate stuck-turn handling + +The first option still looks cleaner. + +#### No advisory-lock pattern found in current code + +I searched for advisory-lock / turn-lock patterns and did **not** find existing Postgres advisory-lock usage or an equivalent in-memory agent turn registry in the current agent code. + +This strengthens the earlier conclusion that scheduled turns will likely need a brand-new serialization mechanism rather than plugging into an existing one. + +#### Additional open questions from this pass + +- Should scheduled-event execution use the same general lease-state machine shape as push deliveries, but with a lease-renewal API added? +- What should the initial lease TTL be for a scheduled turn, given that tool-augmented agent turns can be much longer than provider sends? +- Should turn serialization be implemented at the database level, in app memory, or both? + +### 17.6 Research pass 6 — current agent route surface + +Relevant files reviewed in this pass: + +- `src/web/agent/mod.rs` + +#### Current agent API surface + +The current agent router exposes: + +- `/v1/agent` +- `/v1/agent/init` +- `/v1/agent/items` +- `/v1/agent/chat` +- `/v1/agent/subagents` +- `/v1/agent/subagents/:id/chat` +- related item and delete routes + +There is currently **no** schedule-specific API surface yet for: + +- create scheduled task +- list scheduled tasks +- cancel scheduled task + +That is expected, but it is now confirmed from code. + +#### Current routing is gated to local/dev + +The current agent router is only enabled when `app_mode` is `Local` or `Dev`. + +That means any initial schedule API work in this area will naturally follow the same experimental surface unless we intentionally broaden that exposure later. + +#### Implication for the feature + +If we introduce schedule endpoints as part of the agent API, the most natural home appears to be under the existing agent surface, likely alongside main-agent and subagent routes rather than under push routes. + +That fits the product model already captured earlier: scheduled events are agent behavior, not push-device plumbing. + +#### Open questions from this pass + +- Should scheduling routes live under `/v1/agent/...` and `/v1/agent/subagents/:id/...`, or should creation happen only via agent tool use at first? +- Do we want a direct client-management surface for list/cancel in v1, or is the first pass tool-driven only? +- If these remain under the current experimental agent router, is that sufficient for the intended rollout path? + +--- + +## 18. Decisions Captured During Review + +These are user-confirmed decisions made after the research pass. + +### 18.1 Concurrency with live turns in v1 + +For v1, a scheduled wakeup may run with its **own lifetime** even if that same agent is already mid-turn on a live conversation. + +That means: + +- we are **not** blocking v1 on solving agent-turn merging +- we are **not** blocking v1 on building a strong per-agent serialization mechanism first +- parallel execution for this edge case is acceptable in v1 + +This should be treated as an explicit temporary product tradeoff, not an accidental bug. + +### 18.2 Partial-failure policy after user-visible output + +Normal completion-level retries inside the agent runtime can stay as they are. + +However, if a scheduled turn has already produced and persisted user-visible assistant output, and then later fails after exhausting its normal retries, v1 should: + +- keep the already-produced output +- treat the scheduled event as effectively **completed** +- **not** retry the entire scheduled event from the top + +Rationale: + +- retrying after user-visible output risks duplicate or confusing messages +- preserving partial useful output is better than replaying the whole scheduled wakeup + +This implies an important execution rule for v1: + +- scheduled-event retries are appropriate when the turn remains effectively silent +- once the user has already received meaningful output from that scheduled turn, the system should prefer completion over replay + +### 18.3 Default expiry policy + +The default stale/expiry cutoff for scheduled wakeups in v1 should be: + +- **15 minutes** + +This is especially appropriate for wake-up-style events where usefulness drops quickly after the intended time. + +### 18.4 Client/API surface for v1 + +The first pass should be: + +- **tool-driven only** + +That means we do **not** need a broad direct schedule-management API surface in the initial implementation just to validate the scheduled-agent-wakeup model. + +--- + +## 19. Recurring Schedule Model + +Recurring schedules are importantly different from one-off wakeups. + +For a one-off event, the system stores one future wakeup and runs it once. + +For a recurring event like: + +> Every morning at 8am, look up the weather and tell me. + +the system should **not** be modeled as an infinite stream of pre-created future tasks. Instead, it should be modeled as a durable recurring schedule definition that produces concrete occurrences over time. + +### 19.1 Do not use literal OS cron jobs + +We should not depend on host-level cron or per-user OS cron entries. + +This should stay an application-level scheduler with Postgres as the durable source of truth, just like the broader scheduled-agent-wakeup design. + +The recurrence engine may use cron-like logic internally, but the product abstraction should remain: + +- store schedule definition +- compute next due occurrence +- wake the agent for that occurrence +- update the schedule to the next occurrence + +### 19.2 Two-layer model: schedule definition plus occurrences + +The clean recurring model is: + +1. **Recurring schedule definition** + - which agent it belongs to + - recurrence rule + - timezone + - encrypted future-agent instruction template + - default expiry / stale window + - active / cancelled state + +2. **Concrete occurrence / run** + - this specific 8am firing + - scheduled-for timestamp + - lease / retry state + - execution result / terminal status + +This matters for several reasons: + +- recurring schedules need stable long-lived identity +- each occurrence needs its own retry/lease behavior +- occurrence-level state should not mutate the canonical recurrence definition +- observability and idempotency are easier when each firing is concrete + +### 19.3 Recurrence should be stored in local-time semantics + +For human schedules like: + +- every day at 8am +- weekdays at 7:30am +- every Monday at 9am + +the important semantic is **wall-clock local time**, not a fixed UTC interval. + +So the durable schedule definition should preserve: + +- the user’s IANA timezone +- the local recurrence rule + +Example: + +- timezone: `America/Chicago` +- recurrence: daily at `08:00` + +The resulting UTC timestamp will naturally change across DST transitions, but the user still experiences “8am every morning,” which is what we want. + +### 19.4 Prefer structured recurrence over raw cron as the primary product shape + +For v1, I would strongly prefer a structured recurrence model over exposing raw cron expressions to the agent or user. + +For example, represent recurring schedules as fields like: + +- frequency: daily / weekly +- local_time: `08:00` +- weekdays: optional set +- timezone: IANA timezone + +rather than asking the model to write raw cron strings. + +Reasons: + +- easier for the agent to author correctly +- easier to validate +- clearer DST behavior +- fewer parsing edge cases +- better product ergonomics for later client surfaces + +If needed, we can still compile that structured rule into cron-like evaluation logic internally. + +### 19.5 Compute the next occurrence from the schedule, not from “now” + +The next firing time should be computed from the schedule definition and the last scheduled occurrence, not by repeatedly adding a fixed duration to the current time. + +This avoids drift. + +For example, “every morning at 8am” should not gradually wander because a worker happened to process yesterday’s run at 8:02am. + +The schedule should still target the next proper local 8:00am occurrence. + +### 19.6 Materialize occurrences one at a time + +We should not eagerly create months of future runs. + +Instead, the scheduler should typically: + +- maintain the next due occurrence for the recurring schedule +- when that occurrence is claimed, create or mark a concrete run +- after terminal completion / skip, compute and persist the next due occurrence + +This keeps state smaller and makes edits/cancellation much simpler. + +### 19.7 Recurring schedules should still wake the agent, not send canned reminders + +The recurring model does **not** change the core product philosophy. + +Each occurrence should still wake the agent as a normal background turn. + +So for: + +> Every morning at 8am, look up the weather and tell me. + +the recurring schedule should store a future-agent instruction template like: + +> Each morning at 8am local time, check the weather for the user and send a concise useful morning weather update. If there is newer context that changes what would be helpful, adapt to it. + +At each occurrence, the runtime should inject occurrence-specific metadata like: + +- this occurrence was scheduled for 2026-03-24 08:00 America/Chicago +- this is part of a recurring schedule +- current local time is ... + +The agent then decides what to do with current context just like a one-off scheduled wakeup. + +### 19.8 Backfill policy matters a lot for recurring schedules + +Recurring schedules raise an important question: + +If the system is down or delayed, should it run missed occurrences later? + +For the kind of recurring schedules we are discussing, the default v1 answer should be: + +- **no large backfills by default** + +Instead: + +- if the occurrence is still inside its stale window, it can run late +- if it is past the stale window, mark that occurrence skipped/expired and move on to the next recurrence + +This prevents nonsense behavior like sending several stale morning weather notifications after the fact. + +### 19.9 DST and ambiguous-time policy must be explicit + +Recurring schedules have timezone edge cases that one-off schedules mostly avoid. + +At minimum, the implementation needs explicit behavior for: + +- daylight saving time spring-forward gaps +- daylight saving time fall-back repeated hours + +For common cases like 8am daily reminders, the behavior is straightforward: fire at local 8am and let the UTC time shift with DST. + +For rarer ambiguous times, we should document a policy rather than leave it accidental. + +### 19.10 Cancellation semantics + +Cancelling a recurring schedule should mean: + +- stop generating future occurrences +- cancel any pending unstarted occurrence rows if they already exist +- do not affect already completed historical runs + +This is another reason the schedule-definition / occurrence split is helpful. + +### 19.11 Relationship to v1 tool-driven scope + +Even if v1 is tool-driven only, recurring schedules still need enough internal structure for the agent to create them safely. + +That means the schedule tool should be able to distinguish: + +- one-off wakeup +- recurring wakeup + +without forcing the agent to encode every recurrence as a raw cron string. + +### 19.12 Recommended v1 stance on recurrence + +Given the current product discussion, the safest v1 recurrence shape is: + +- daily / weekly recurring schedules only +- timezone-aware local-time execution +- structured recurrence fields, not raw cron strings +- one concrete occurrence at a time +- stale occurrences skipped instead of heavily backfilled +- same background-agent execution model as one-off events + +That would cover common product cases like: + +- every morning at 8am, send weather +- weekdays at 7am, remind me to wake up +- every Sunday evening, remind me to plan the week + +without taking on the full complexity of arbitrary cron semantics immediately. + +### 19.13 Distributed scheduling across multiple OpenSecret servers + +This system must assume that multiple OpenSecret servers may be running the same code concurrently against the same Postgres database. + +That means recurring scheduling must be **database-coordinated**, not process-local. + +Important implications: + +- no server-local in-memory timers as the source of truth +- no assumption that one server “owns” a user’s schedules +- no schedule state that only exists in process memory + +Postgres must remain the durable coordination plane. + +#### Recommended distributed model + +For both one-off and recurring schedules: + +- workers on any server may poll for due rows +- claiming work must happen through row leasing / transactional state changes +- lease ownership must be checked again when writing terminal results + +For recurring schedules specifically, there are two related coordination problems: + +1. **Who claims the due occurrence for execution?** +2. **Who advances the recurring schedule to its next occurrence?** + +Both need to be safe under concurrent workers on different servers. + +#### Suggested safety pattern for recurring schedules + +The safest shape is: + +- keep the recurring schedule definition row in Postgres +- when an occurrence is due, claim/update it transactionally +- generate a concrete occurrence/run row with a unique occurrence key +- advance the schedule definition to the next due local-time occurrence in the same transaction, or under the same row lock + +To avoid duplicates, recurring occurrences should have a uniqueness guarantee like: + +- unique `(schedule_id, scheduled_for_at)` + +or another stable occurrence identity derived from the recurrence. + +That way, even if two servers race, the database prevents duplicate occurrence creation. + +#### Reuse the existing push-worker philosophy + +The current push system already uses the right distributed philosophy: + +- `FOR UPDATE SKIP LOCKED` +- lease owner fields +- lease expiry +- compare-and-set style writeback + +Scheduled-agent wakeups should follow the same philosophy, even if the exact schema differs. + +#### Important consequence for future turn serialization + +Because multiple OpenSecret servers may execute turns, any future “one active turn per agent” policy cannot rely only on in-memory locks. + +If we later tighten serialization beyond the v1 parallel-turn tradeoff, that coordination will need to be database-backed or otherwise distributed-safe. + +### 19.14 Timezone-following versus fixed-timezone schedules + +User timezone updates matter a lot for human schedules like: + +- wake me up at 8am +- every morning at 8am, tell me the weather +- remind me at 6pm to leave + +These often mean: + +- “8am where I am” + +not: + +- “8am forever in the timezone I happened to be in when I created the schedule” + +So the scheduler should support two distinct timezone behaviors: + +1. **Floating / follow-user-timezone schedules** + - the schedule follows the user’s current timezone preference + - best default for personal routine reminders and wake-up-style schedules + +2. **Fixed-timezone schedules** + - the schedule stays anchored to a specific IANA timezone + - appropriate when the user explicitly anchors it, e.g. “9am Eastern” + +This distinction is standard and important. Calendaring systems often make the same separation between floating local-time events and explicitly zoned events. + +#### Recommended product default + +For the kind of personal schedules we are discussing, the default should usually be: + +- **follow the user’s timezone preference** + +unless the user or agent explicitly anchors the schedule to a fixed timezone. + +#### What the timezone update tool should do + +If the user’s timezone preference is updated, the system should proactively recompute future schedules that are marked as: + +- active, and +- follow-user-timezone + +It should **not** touch: + +- historical completed occurrences +- cancelled schedules +- schedules explicitly pinned to a fixed timezone + +Because the design already prefers one concrete occurrence at a time, this recalculation should stay relatively cheap: + +- update the schedule definition’s `next_due_at` +- update or replace any unclaimed pending future occurrence for that schedule if one already exists + +There is no need to rewrite a large history of future rows if we keep materialization bounded. + +#### Important schema implication + +For follow-user-timezone schedules, we cannot store only an absolute UTC timestamp and expect timezone changes to work correctly. + +We also need to preserve the **local-time intent**, for example: + +- local recurrence rule +- local clock time +- timezone mode (`follow_user` vs `fixed`) +- fixed timezone value when applicable + +Then `next_due_at` becomes a computed operational field derived from that intent. + +#### One-off schedules + +This distinction also applies to one-off schedules. + +If the product wants a one-off “8am tomorrow” wakeup to follow the user when they travel, then that one-off event also needs to preserve local-time intent rather than only a resolved UTC timestamp. + +If instead a one-off is meant to stay pinned to the original timezone, it should be marked fixed. + +So the schedule tool should ideally decide this explicitly when creating the event, rather than leaving the system to guess later. + +### 19.15 Agent-friendly v1 recurrence parameters + +The scheduling interface should be simple enough that the agent can reliably map common user intent into correct parameters without being forced to invent raw cron syntax. + +The right v1 model is not “free-form cron strings.” It is a small structured recurrence vocabulary that covers the common cases well. + +#### Recommended v1 recurrence families + +Support these recurring families in v1: + +1. **Interval** + - example: every hour + - fields like: + - `every_n` + - `unit` = `hours` + +2. **Daily wall-clock** + - examples: every morning at 8am, every day at 6pm + - fields like: + - `local_time` + - timezone behavior + +3. **Weekly wall-clock** + - examples: every Friday at 9am, weekdays at 7:30am + - fields like: + - `weekdays` + - `local_time` + - timezone behavior + +This already covers most of the common recurring schedules we care about. + +#### Example parameter shape + +Conceptually, a tool/API shape like this is enough for v1: + +- `schedule_kind`: `one_off` | `recurring` +- `instruction`: future-agent instruction text +- `description`: short operational summary +- `timezone_mode`: `follow_user` | `fixed` +- `fixed_timezone`: optional IANA timezone when pinned +- `stale_after_minutes`: optional override + +For one-off: + +- `local_date` +- `local_time` + +For recurring interval: + +- `recurrence_type`: `interval` +- `every_n` +- `interval_unit`: `hours` + +For recurring daily: + +- `recurrence_type`: `daily` +- `local_time` + +For recurring weekly: + +- `recurrence_type`: `weekly` +- `weekdays` +- `local_time` + +That is much easier for the agent to use correctly than arbitrary cron. + +#### How common examples map + +- “every morning at 8am” + - recurring + - daily + - `local_time = 08:00` + - `timezone_mode = follow_user` + +- “every Friday at 5pm” + - recurring + - weekly + - `weekdays = [friday]` + - `local_time = 17:00` + +- “every hour” + - recurring + - interval + - `every_n = 1` + - `interval_unit = hours` + +- “every weekday at 7:30am” + - recurring + - weekly + - `weekdays = [monday, tuesday, wednesday, thursday, friday]` + - `local_time = 07:30` + +#### Important distinction: interval versus wall-clock recurrence + +“Every hour” is fundamentally different from “every day at 8am.” + +- interval schedules are duration-based +- daily/weekly schedules are wall-clock based + +That distinction should be explicit in the schedule definition, because it affects DST handling, drift, and how the next occurrence is computed. + +#### Is this standard practice? + +Yes. + +It is very normal to: + +- own the schedule schema and distributed execution model +- use a limited structured recurrence model for product ergonomics +- defer complex calendar recurrences until later +- rely on a recurrence library internally rather than exposing raw recurrence syntax directly + +This is a sound and scalable engineering approach, especially for a multi-server application using Postgres as the coordination plane. diff --git a/migrations/2026-02-10-020612_user_embeddings/down.sql b/migrations/2026-02-10-020612_user_embeddings/down.sql new file mode 100644 index 00000000..2be284ca --- /dev/null +++ b/migrations/2026-02-10-020612_user_embeddings/down.sql @@ -0,0 +1,12 @@ +DROP TRIGGER IF EXISTS update_user_embeddings_updated_at ON user_embeddings; + +DROP INDEX IF EXISTS idx_user_embeddings_user_id; +DROP INDEX IF EXISTS idx_user_embeddings_user_created; +DROP INDEX IF EXISTS idx_user_embeddings_user_source; +DROP INDEX IF EXISTS idx_user_embeddings_archival_tags_enc; +DROP INDEX IF EXISTS idx_user_embeddings_user_conversation; +DROP INDEX IF EXISTS idx_user_embeddings_user_message_id; +DROP INDEX IF EXISTS idx_user_embeddings_assistant_message_id; +DROP INDEX IF EXISTS idx_user_embeddings_model; + +DROP TABLE IF EXISTS user_embeddings; diff --git a/migrations/2026-02-10-020612_user_embeddings/up.sql b/migrations/2026-02-10-020612_user_embeddings/up.sql new file mode 100644 index 00000000..76621fb6 --- /dev/null +++ b/migrations/2026-02-10-020612_user_embeddings/up.sql @@ -0,0 +1,91 @@ +-- =========================================================== +-- User Embeddings (Phase 1 RAG brute-force infrastructure) +-- =========================================================== + +CREATE TABLE user_embeddings ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL UNIQUE, + user_id UUID NOT NULL REFERENCES users(uuid) ON DELETE CASCADE, + + -- Source tracking + -- v1 uses: 'message', 'archival'. Keep as open TEXT for future types. + source_type TEXT NOT NULL DEFAULT 'message', + + -- Message provenance (ONLY for source_type='message') + user_message_id BIGINT REFERENCES user_messages(id) ON DELETE CASCADE, + assistant_message_id BIGINT REFERENCES assistant_messages(id) ON DELETE CASCADE, + conversation_id BIGINT REFERENCES conversations(id) ON DELETE CASCADE, + + -- Embedding vector + vector_enc BYTEA NOT NULL, -- AES-256-GCM encrypted float32 array + embedding_model TEXT NOT NULL, -- e.g. "nomic-embed-text" + vector_dim INTEGER NOT NULL DEFAULT 768, + + -- Content that was embedded + content_enc BYTEA NOT NULL, + metadata_enc BYTEA, + + -- Deterministically-encrypted tags (base64) for SQL-level filtering + tags_enc TEXT[] NOT NULL DEFAULT '{}', + + -- Plaintext metadata + token_count INTEGER NOT NULL, + + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- Enforce invariants for the v1 source types without blocking future types. + CONSTRAINT user_embeddings_message_source_check CHECK ( + (source_type <> 'message') + OR ( + -- exactly one of the message FKs must be set + (user_message_id IS NOT NULL) <> (assistant_message_id IS NOT NULL) + ) + ), + CONSTRAINT user_embeddings_message_conversation_check CHECK ( + (source_type <> 'message') OR (conversation_id IS NOT NULL) + ), + CONSTRAINT user_embeddings_archival_source_check CHECK ( + (source_type <> 'archival') + OR ( + user_message_id IS NULL + AND assistant_message_id IS NULL + AND conversation_id IS NULL + ) + ) +); + +-- Primary query path: load all vectors for a user (brute-force scan) +CREATE INDEX idx_user_embeddings_user_id ON user_embeddings(user_id); + +-- For time-filtered searches (recency bias) +CREATE INDEX idx_user_embeddings_user_created ON user_embeddings(user_id, created_at DESC); + +-- For source-type filtered searches (message vs archival) +CREATE INDEX idx_user_embeddings_user_source ON user_embeddings(user_id, source_type); + +-- For tag-filtered archival searches (encrypted, per-user tokens) +CREATE INDEX idx_user_embeddings_archival_tags_enc + ON user_embeddings USING GIN(tags_enc) + WHERE source_type = 'archival'; + +-- For conversation-scoped searches (message recall) +CREATE INDEX idx_user_embeddings_user_conversation ON user_embeddings(user_id, conversation_id); + +-- Idempotency/deduplication: prevent double-indexing the same message +CREATE UNIQUE INDEX idx_user_embeddings_user_message_id + ON user_embeddings(user_message_id) + WHERE user_message_id IS NOT NULL; + +CREATE UNIQUE INDEX idx_user_embeddings_assistant_message_id + ON user_embeddings(assistant_message_id) + WHERE assistant_message_id IS NOT NULL; + +-- For staleness queries: find vectors that need re-embedding after model change +CREATE INDEX idx_user_embeddings_model ON user_embeddings(user_id, embedding_model); + +-- Trigger for updated_at +-- Note: update_updated_at_column() function already exists from previous migrations +CREATE TRIGGER update_user_embeddings_updated_at +BEFORE UPDATE ON user_embeddings +FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/migrations/2026-02-10-214235_maple_agent_mvp_storage/down.sql b/migrations/2026-02-10-214235_maple_agent_mvp_storage/down.sql new file mode 100644 index 00000000..21a3e325 --- /dev/null +++ b/migrations/2026-02-10-214235_maple_agent_mvp_storage/down.sql @@ -0,0 +1,13 @@ +DROP TRIGGER IF EXISTS update_agents_updated_at ON agents; +DROP INDEX IF EXISTS idx_agents_parent_agent_id; +DROP INDEX IF EXISTS idx_agents_user_kind_created; +DROP INDEX IF EXISTS idx_agents_one_main_per_user; +DROP TABLE IF EXISTS agents; + +DROP INDEX IF EXISTS idx_conversation_summaries_chain; +DROP INDEX IF EXISTS idx_conversation_summaries_user_conv; +DROP TABLE IF EXISTS conversation_summaries; + +DROP TRIGGER IF EXISTS update_memory_blocks_updated_at ON memory_blocks; +DROP INDEX IF EXISTS idx_memory_blocks_user_id; +DROP TABLE IF EXISTS memory_blocks; diff --git a/migrations/2026-02-10-214235_maple_agent_mvp_storage/up.sql b/migrations/2026-02-10-214235_maple_agent_mvp_storage/up.sql new file mode 100644 index 00000000..8feb4e4c --- /dev/null +++ b/migrations/2026-02-10-214235_maple_agent_mvp_storage/up.sql @@ -0,0 +1,89 @@ +-- Maple MVP agent storage tables +-- +-- Note: update_updated_at_column() already exists from previous migrations. + +CREATE TABLE memory_blocks ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_id UUID NOT NULL REFERENCES users(uuid) ON DELETE CASCADE, + + label TEXT NOT NULL, + value_enc BYTEA NOT NULL, + + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(user_id, label) +); + +CREATE INDEX idx_memory_blocks_user_id ON memory_blocks(user_id); + +CREATE TRIGGER update_memory_blocks_updated_at +BEFORE UPDATE ON memory_blocks +FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TABLE conversation_summaries ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_id UUID NOT NULL REFERENCES users(uuid) ON DELETE CASCADE, + conversation_id BIGINT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + + from_created_at TIMESTAMPTZ NOT NULL, + to_created_at TIMESTAMPTZ NOT NULL, + message_count INTEGER NOT NULL, + + content_enc BYTEA NOT NULL, + content_tokens INTEGER NOT NULL, + + embedding_enc BYTEA, + + previous_summary_id BIGINT REFERENCES conversation_summaries(id) ON DELETE SET NULL, + + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT valid_time_range CHECK (from_created_at <= to_created_at) +); + +CREATE INDEX idx_conversation_summaries_user_conv + ON conversation_summaries(user_id, conversation_id, created_at DESC); + +CREATE INDEX idx_conversation_summaries_chain + ON conversation_summaries(previous_summary_id); + +CREATE TABLE agents ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_id UUID NOT NULL REFERENCES users(uuid) ON DELETE CASCADE, + + conversation_id BIGINT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + parent_agent_id BIGINT REFERENCES agents(id) ON DELETE SET NULL, + display_name_enc BYTEA, + purpose_enc BYTEA, + created_by TEXT NOT NULL DEFAULT 'user', + + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT agents_kind_check CHECK (kind IN ('main', 'subagent')), + CONSTRAINT agents_created_by_check CHECK (created_by IN ('user', 'agent')), + CONSTRAINT agents_parent_check CHECK ( + (kind = 'main' AND parent_agent_id IS NULL) + OR (kind = 'subagent' AND parent_agent_id IS NOT NULL) + ), + UNIQUE(conversation_id) +); + +CREATE UNIQUE INDEX idx_agents_one_main_per_user + ON agents(user_id) + WHERE kind = 'main'; + +CREATE INDEX idx_agents_user_kind_created + ON agents(user_id, kind, created_at DESC); + +CREATE INDEX idx_agents_parent_agent_id + ON agents(parent_agent_id); + +CREATE TRIGGER update_agents_updated_at +BEFORE UPDATE ON agents +FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/migrations/2026-02-24-150000_user_messages_attachment_text/down.sql b/migrations/2026-02-24-150000_user_messages_attachment_text/down.sql new file mode 100644 index 00000000..4687cd06 --- /dev/null +++ b/migrations/2026-02-24-150000_user_messages_attachment_text/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_messages + DROP COLUMN attachment_text_enc; diff --git a/migrations/2026-02-24-150000_user_messages_attachment_text/up.sql b/migrations/2026-02-24-150000_user_messages_attachment_text/up.sql new file mode 100644 index 00000000..6a49f909 --- /dev/null +++ b/migrations/2026-02-24-150000_user_messages_attachment_text/up.sql @@ -0,0 +1,7 @@ +-- Add optional derived image description storage for user messages +-- +-- This stores Maple-style vision pre-processing results (encrypted in app layer) +-- without mutating the original MessageContent JSON. + +ALTER TABLE user_messages + ADD COLUMN attachment_text_enc BYTEA; diff --git a/migrations/2026-03-07-120000_push_notifications_v1/down.sql b/migrations/2026-03-07-120000_push_notifications_v1/down.sql new file mode 100644 index 00000000..e578d4bd --- /dev/null +++ b/migrations/2026-03-07-120000_push_notifications_v1/down.sql @@ -0,0 +1,10 @@ +DROP TRIGGER IF EXISTS update_notification_deliveries_updated_at ON notification_deliveries; +DROP TRIGGER IF EXISTS update_push_devices_updated_at ON push_devices; + +DROP INDEX IF EXISTS idx_notification_deliveries_pending; +DROP INDEX IF EXISTS idx_notification_events_user_due; +DROP INDEX IF EXISTS idx_push_devices_user_active; + +DROP TABLE IF EXISTS notification_deliveries; +DROP TABLE IF EXISTS notification_events; +DROP TABLE IF EXISTS push_devices; diff --git a/migrations/2026-03-07-120000_push_notifications_v1/up.sql b/migrations/2026-03-07-120000_push_notifications_v1/up.sql new file mode 100644 index 00000000..61d1a7ef --- /dev/null +++ b/migrations/2026-03-07-120000_push_notifications_v1/up.sql @@ -0,0 +1,90 @@ +CREATE TABLE push_devices ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT uuid_generate_v4() UNIQUE, + user_id UUID NOT NULL REFERENCES users(uuid) ON DELETE CASCADE, + installation_id UUID NOT NULL, + platform TEXT NOT NULL CHECK (platform IN ('ios', 'android')), + provider TEXT NOT NULL CHECK (provider IN ('apns', 'fcm')), + environment TEXT NOT NULL CHECK (environment IN ('dev', 'prod')), + app_id TEXT NOT NULL, + push_token_enc BYTEA NOT NULL, + push_token_hash BYTEA NOT NULL, + notification_public_key BYTEA NOT NULL, + key_algorithm TEXT NOT NULL CHECK (key_algorithm IN ('p256_ecdh_v1')), + supports_encrypted_preview BOOLEAN NOT NULL DEFAULT false, + supports_background_processing BOOLEAN NOT NULL DEFAULT false, + last_seen_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + revoked_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK ( + (platform = 'ios' AND provider = 'apns') OR + (platform = 'android' AND provider = 'fcm') + ) +); + +CREATE TABLE notification_events ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT uuid_generate_v4() UNIQUE, + project_id INTEGER NOT NULL REFERENCES org_projects(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(uuid) ON DELETE CASCADE, + kind TEXT NOT NULL, + delivery_mode TEXT NOT NULL CHECK (delivery_mode IN ('generic', 'encrypted_preview')), + priority TEXT NOT NULL DEFAULT 'normal' CHECK (priority IN ('normal', 'high')), + collapse_key TEXT, + fallback_title TEXT NOT NULL, + fallback_body TEXT NOT NULL, + payload_enc BYTEA, + not_before_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + cancelled_at TIMESTAMP WITH TIME ZONE +); + +CREATE TABLE notification_deliveries ( + id BIGSERIAL PRIMARY KEY, + event_id BIGINT NOT NULL REFERENCES notification_events(id) ON DELETE CASCADE, + push_device_id BIGINT NOT NULL REFERENCES push_devices(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending' CHECK ( + status IN ('pending', 'leased', 'sent', 'retry', 'failed', 'invalid_token', 'cancelled') + ), + attempt_count INTEGER NOT NULL DEFAULT 0, + next_attempt_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + lease_owner TEXT, + lease_expires_at TIMESTAMP WITH TIME ZONE, + provider_message_id TEXT, + provider_status_code INTEGER, + last_error TEXT, + sent_at TIMESTAMP WITH TIME ZONE, + invalidated_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (event_id, push_device_id) +); + +CREATE INDEX idx_push_devices_user_active + ON push_devices(user_id, revoked_at); + +CREATE UNIQUE INDEX idx_push_devices_installation_active + ON push_devices(installation_id, environment) + WHERE revoked_at IS NULL; + +CREATE UNIQUE INDEX idx_push_devices_token_active + ON push_devices(provider, environment, push_token_hash) + WHERE revoked_at IS NULL; + +CREATE INDEX idx_notification_events_user_due + ON notification_events(user_id, not_before_at, cancelled_at); + +CREATE INDEX idx_notification_deliveries_pending + ON notification_deliveries(status, next_attempt_at); + +CREATE TRIGGER update_push_devices_updated_at + BEFORE UPDATE ON push_devices + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_notification_deliveries_updated_at + BEFORE UPDATE ON notification_deliveries + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); diff --git a/migrations/2026-03-22-033535_add_user_preferences/down.sql b/migrations/2026-03-22-033535_add_user_preferences/down.sql new file mode 100644 index 00000000..b476af4e --- /dev/null +++ b/migrations/2026-03-22-033535_add_user_preferences/down.sql @@ -0,0 +1,3 @@ +DROP TRIGGER IF EXISTS update_user_preferences_updated_at ON user_preferences; +DROP INDEX IF EXISTS idx_user_preferences_user_id; +DROP TABLE IF EXISTS user_preferences; diff --git a/migrations/2026-03-22-033535_add_user_preferences/up.sql b/migrations/2026-03-22-033535_add_user_preferences/up.sql new file mode 100644 index 00000000..bf39303f --- /dev/null +++ b/migrations/2026-03-22-033535_add_user_preferences/up.sql @@ -0,0 +1,16 @@ +CREATE TABLE user_preferences ( + id BIGSERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(uuid) ON DELETE CASCADE, + key TEXT NOT NULL, + value_enc BYTEA NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, key) +); + +CREATE INDEX idx_user_preferences_user_id ON user_preferences(user_id); + +CREATE TRIGGER update_user_preferences_updated_at +BEFORE UPDATE ON user_preferences +FOR EACH ROW +EXECUTE FUNCTION update_updated_at_column(); diff --git a/migrations/2026-03-23-180000_agent_schedules_v1/down.sql b/migrations/2026-03-23-180000_agent_schedules_v1/down.sql new file mode 100644 index 00000000..213f4b73 --- /dev/null +++ b/migrations/2026-03-23-180000_agent_schedules_v1/down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS agent_schedule_runs; +DROP TABLE IF EXISTS agent_schedules; diff --git a/migrations/2026-03-23-180000_agent_schedules_v1/up.sql b/migrations/2026-03-23-180000_agent_schedules_v1/up.sql new file mode 100644 index 00000000..9b2b9958 --- /dev/null +++ b/migrations/2026-03-23-180000_agent_schedules_v1/up.sql @@ -0,0 +1,89 @@ +CREATE TABLE agent_schedules ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + user_id UUID NOT NULL REFERENCES users(uuid) ON DELETE CASCADE, + agent_id BIGINT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + description TEXT NOT NULL, + instruction_enc BYTEA NOT NULL, + schedule_kind TEXT NOT NULL CHECK (schedule_kind IN ('one_off', 'recurring')), + recurrence_type TEXT CHECK ( + recurrence_type IS NULL OR recurrence_type IN ('interval', 'daily', 'weekly') + ), + schedule_spec JSONB NOT NULL, + timezone_mode TEXT NOT NULL CHECK (timezone_mode IN ('follow_user', 'fixed')), + resolved_timezone TEXT NOT NULL, + fixed_timezone TEXT, + stale_after_minutes INTEGER NOT NULL DEFAULT 15 CHECK (stale_after_minutes > 0), + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'completed', 'cancelled')), + next_scheduled_for TIMESTAMP WITH TIME ZONE, + last_scheduled_for TIMESTAMP WITH TIME ZONE, + last_run_at TIMESTAMP WITH TIME ZONE, + run_count INTEGER NOT NULL DEFAULT 0 CHECK (run_count >= 0), + cancelled_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT agent_schedules_fixed_timezone_check CHECK ( + (timezone_mode = 'follow_user' AND fixed_timezone IS NULL) + OR (timezone_mode = 'fixed' AND fixed_timezone IS NOT NULL) + ), + CONSTRAINT agent_schedules_kind_recurrence_check CHECK ( + (schedule_kind = 'one_off' AND recurrence_type IS NULL) + OR (schedule_kind = 'recurring' AND recurrence_type IS NOT NULL) + ) +); + +CREATE TABLE agent_schedule_runs ( + id BIGSERIAL PRIMARY KEY, + uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + schedule_id BIGINT NOT NULL REFERENCES agent_schedules(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(uuid) ON DELETE CASCADE, + agent_id BIGINT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + scheduled_for TIMESTAMP WITH TIME ZONE NOT NULL, + stale_after_at TIMESTAMP WITH TIME ZONE NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK ( + status IN ('pending', 'leased', 'retry', 'completed', 'failed', 'cancelled', 'expired') + ), + attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + next_attempt_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + lease_owner TEXT, + lease_expires_at TIMESTAMP WITH TIME ZONE, + started_at TIMESTAMP WITH TIME ZONE, + first_output_at TIMESTAMP WITH TIME ZONE, + first_message_id UUID, + output_count INTEGER NOT NULL DEFAULT 0 CHECK (output_count >= 0), + notification_enqueued_at TIMESTAMP WITH TIME ZONE, + completed_at TIMESTAMP WITH TIME ZONE, + last_error TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (schedule_id, scheduled_for) +); + +CREATE INDEX idx_agent_schedules_active_due + ON agent_schedules(status, next_scheduled_for, id) + WHERE status = 'active'; + +CREATE INDEX idx_agent_schedules_agent_status + ON agent_schedules(agent_id, status, next_scheduled_for); + +CREATE INDEX idx_agent_schedules_user_status + ON agent_schedules(user_id, status, created_at DESC); + +CREATE INDEX idx_agent_schedule_runs_pending + ON agent_schedule_runs(status, next_attempt_at, id); + +CREATE INDEX idx_agent_schedule_runs_schedule + ON agent_schedule_runs(schedule_id, scheduled_for DESC); + +CREATE INDEX idx_agent_schedule_runs_agent_status + ON agent_schedule_runs(agent_id, status, scheduled_for DESC); + +CREATE TRIGGER update_agent_schedules_updated_at + BEFORE UPDATE ON agent_schedules + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_agent_schedule_runs_updated_at + BEFORE UPDATE ON agent_schedule_runs + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); diff --git a/migrations/2026-03-24-220000_agent_message_reactions/down.sql b/migrations/2026-03-24-220000_agent_message_reactions/down.sql new file mode 100644 index 00000000..b9b00b64 --- /dev/null +++ b/migrations/2026-03-24-220000_agent_message_reactions/down.sql @@ -0,0 +1,5 @@ +ALTER TABLE assistant_messages + DROP COLUMN IF EXISTS user_reaction; + +ALTER TABLE user_messages + DROP COLUMN IF EXISTS assistant_reaction; diff --git a/migrations/2026-03-24-220000_agent_message_reactions/up.sql b/migrations/2026-03-24-220000_agent_message_reactions/up.sql new file mode 100644 index 00000000..bf4b9361 --- /dev/null +++ b/migrations/2026-03-24-220000_agent_message_reactions/up.sql @@ -0,0 +1,5 @@ +ALTER TABLE user_messages + ADD COLUMN assistant_reaction TEXT; + +ALTER TABLE assistant_messages + ADD COLUMN user_reaction TEXT; diff --git a/src/brave.rs b/src/brave.rs index 7f34e345..d345b794 100644 --- a/src/brave.rs +++ b/src/brave.rs @@ -1,11 +1,7 @@ -//! Minimal Brave Search API client -//! -//! This module provides a lightweight client for the Brave Search API. -//! Only includes what we actually use - no bloat from auto-generated code. - use serde::{Deserialize, Serialize}; use std::sync::Arc; use std::time::Duration; +use tracing::{debug, warn}; const BRAVE_API_BASE: &str = "https://api.search.brave.com/res/v1"; const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); @@ -19,7 +15,14 @@ pub enum BraveError { Api { status: u16, message: String }, } -/// Brave API client with reusable HTTP client and stored API key +#[derive(Debug, Clone, Default)] +pub struct SearchOptions { + pub count: Option, + pub freshness: Option, + pub location: Option, + pub timezone: Option, +} + #[derive(Clone)] pub struct BraveClient { client: reqwest::Client, @@ -27,7 +30,6 @@ pub struct BraveClient { } impl BraveClient { - /// Create a new Brave client with the given API key pub fn new(api_key: String) -> Result { let client = reqwest::Client::builder() .timeout(REQUEST_TIMEOUT) @@ -43,7 +45,6 @@ impl BraveClient { }) } - /// Execute a web search query pub async fn search(&self, request: SearchRequest) -> Result { let url = format!("{}/web/search", BRAVE_API_BASE); @@ -56,7 +57,7 @@ impl BraveClient { query_params.push(("search_lang", search_lang.clone())); } if let Some(count) = request.count { - query_params.push(("count", count.to_string())); + query_params.push(("count", count.min(20).to_string())); } if let Some(offset) = request.offset { query_params.push(("offset", offset.to_string())); @@ -70,18 +71,48 @@ impl BraveClient { if let Some(summary) = request.summary { query_params.push(("summary", if summary { "1" } else { "0" }.to_string())); } + if let Some(extra_snippets) = request.extra_snippets { + query_params.push(( + "extra_snippets", + if extra_snippets { "true" } else { "false" }.to_string(), + )); + } + if let Some(enable_rich_callback) = request.enable_rich_callback { + query_params.push(( + "enable_rich_callback", + if enable_rich_callback { "1" } else { "0" }.to_string(), + )); + } + if let Some(spellcheck) = request.spellcheck { + query_params.push(( + "spellcheck", + if spellcheck { "true" } else { "false" }.to_string(), + )); + } - let response = self + let mut request_builder = self .client .get(&url) .header("X-Subscription-Token", self.api_key.as_str()) - .header("Accept", "application/json") - .query(&query_params) - .send() - .await?; + .header("Accept", "application/json"); - let status = response.status(); + if let Some(timezone) = &request.timezone { + request_builder = request_builder.header("x-loc-timezone", timezone); + } + + if let Some(location) = &request.location { + let parts: Vec<&str> = location.split(',').map(|s| s.trim()).collect(); + if let Some(city) = parts.first().filter(|s| !s.is_empty()) { + request_builder = request_builder.header("x-loc-city", *city); + } + if let Some(state_name) = parts.get(1).filter(|s| !s.is_empty()) { + request_builder = request_builder.header("x-loc-state-name", *state_name); + } + } + let response = request_builder.query(&query_params).send().await?; + + let status = response.status(); if !status.is_success() { let error_text = response.text().await.unwrap_or_default(); return Err(BraveError::Api { @@ -90,11 +121,68 @@ impl BraveClient { }); } - let search_response = response.json::().await?; + response + .json::() + .await + .map_err(BraveError::Request) + } + + pub async fn search_with_options( + &self, + query: &str, + options: Option, + ) -> Result { + let options = options.unwrap_or_default(); + let request = SearchRequest { + query: query.to_string(), + country: None, + search_lang: None, + count: options.count.or(Some(10)), + offset: None, + safesearch: None, + freshness: options.freshness, + summary: Some(true), + extra_snippets: Some(true), + enable_rich_callback: Some(true), + spellcheck: Some(true), + location: options.location, + timezone: options.timezone, + }; + + let mut search_response = self.search(request).await?; + + let summarizer_key = search_response.summarizer.as_ref().map(|s| s.key.clone()); + if let Some(key) = summarizer_key { + debug!("Fetching Brave AI summary"); + match self.summarizer(&key).await { + Ok(summary_response) => { + search_response.summary_text = summary_response.extract_text(); + } + Err(err) => { + warn!("Failed to fetch Brave summary: {err}"); + } + } + } + + let rich_callback = search_response + .rich + .as_ref() + .map(|r| (r.hint.vertical.clone(), r.hint.callback_key.clone())); + if let Some((vertical, callback_key)) = rich_callback { + debug!("Fetching Brave rich data for vertical: {vertical}"); + match self.fetch_rich(&callback_key).await { + Ok(rich_response) => { + search_response.rich_data = Some(rich_response); + } + Err(err) => { + warn!("Failed to fetch Brave rich data: {err}"); + } + } + } + Ok(search_response) } - /// Execute a summarizer query using a key from web search pub async fn summarizer(&self, key: &str) -> Result { let url = format!("{}/summarizer/search", BRAVE_API_BASE); @@ -108,7 +196,33 @@ impl BraveClient { .await?; let status = response.status(); + if !status.is_success() { + let error_text = response.text().await.unwrap_or_default(); + return Err(BraveError::Api { + status: status.as_u16(), + message: error_text, + }); + } + response + .json::() + .await + .map_err(BraveError::Request) + } + + async fn fetch_rich(&self, callback_key: &str) -> Result { + let url = format!("{}/web/rich", BRAVE_API_BASE); + + let response = self + .client + .get(&url) + .header("X-Subscription-Token", self.api_key.as_str()) + .header("Accept", "application/json") + .query(&[("callback_key", callback_key)]) + .send() + .await?; + + let status = response.status(); if !status.is_success() { let error_text = response.text().await.unwrap_or_default(); return Err(BraveError::Api { @@ -117,8 +231,10 @@ impl BraveClient { }); } - let summarizer_response = response.json::().await?; - Ok(summarizer_response) + response + .json::() + .await + .map_err(BraveError::Request) } } @@ -147,6 +263,16 @@ pub struct SearchRequest { pub freshness: Option, #[serde(skip_serializing_if = "Option::is_none")] pub summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub extra_snippets: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_rich_callback: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub spellcheck: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub location: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timezone: Option, } impl SearchRequest { @@ -160,64 +286,82 @@ impl SearchRequest { safesearch: None, freshness: None, summary: None, + extra_snippets: None, + enable_rich_callback: None, + spellcheck: None, + location: None, + timezone: None, } } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] #[allow(dead_code)] pub struct SearchResponse { #[serde(rename = "type")] - pub response_type: String, - #[serde(skip_serializing_if = "Option::is_none")] + pub response_type: Option, pub query: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub web: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub news: Option, - #[serde(skip_serializing_if = "Option::is_none")] + pub faq: Option, + pub discussions: Option, pub infobox: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub summarizer: Option, + pub rich: Option, + #[serde(skip)] + pub summary_text: Option, + #[serde(skip)] + pub rich_data: Option, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] #[allow(dead_code)] pub struct QueryInfo { - #[serde(skip_serializing_if = "Option::is_none")] pub original: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub altered: Option, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] #[allow(dead_code)] pub struct WebResults { #[serde(rename = "type")] - pub results_type: String, - #[serde(skip_serializing_if = "Option::is_none")] + pub results_type: Option, pub results: Option>, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] #[allow(dead_code)] pub struct NewsResults { #[serde(rename = "type")] - pub results_type: String, - #[serde(skip_serializing_if = "Option::is_none")] + pub results_type: Option, pub results: Option>, } +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +#[allow(dead_code)] +pub struct FaqResults { + pub results: Option>, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +#[allow(dead_code)] +pub struct DiscussionResults { + pub results: Option>, +} + #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] pub struct SearchResult { pub title: String, pub url: String, - #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub age: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub extra_snippets: Option>, } @@ -226,22 +370,35 @@ pub struct SearchResult { pub struct NewsResult { pub title: String, pub url: String, - #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub age: Option, } #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] +pub struct FaqResult { + pub question: String, + pub answer: String, + pub title: Option, + pub url: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct DiscussionResult { + pub title: String, + pub url: String, + pub description: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +#[allow(dead_code)] pub struct Infobox { #[serde(rename = "type")] - pub infobox_type: String, - #[serde(skip_serializing_if = "Option::is_none")] + pub infobox_type: Option, pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub long_desc: Option, } @@ -249,16 +406,30 @@ pub struct Infobox { #[allow(dead_code)] pub struct Summarizer { #[serde(rename = "type")] - pub summarizer_type: String, + pub summarizer_type: Option, pub key: String, } #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] +pub struct RichHint { + #[serde(rename = "type")] + pub rich_type: Option, + pub hint: RichHintDetails, +} + +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct RichHintDetails { + pub vertical: String, + pub callback_key: String, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +#[allow(dead_code)] pub struct SummarizerSearchResponse { - #[serde(skip_serializing_if = "Option::is_none")] pub status: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub summary: Option>, } @@ -267,8 +438,496 @@ pub struct SummarizerSearchResponse { pub struct SummaryItem { #[serde(rename = "type")] pub item_type: String, - /// For type="token", data is a string with the text - /// For type="enum_item", data is a SummaryEntity object - #[serde(skip_serializing_if = "Option::is_none")] pub data: Option, } + +impl SummarizerSearchResponse { + pub fn extract_text(&self) -> Option { + let items = self.summary.as_ref()?; + let mut text = String::new(); + + for item in items { + if item.item_type == "token" { + if let Some(data) = &item.data { + if let Some(value) = data.as_str() { + text.push_str(value); + } + } + } + } + + if text.is_empty() { + None + } else { + Some(text) + } + } +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +#[allow(dead_code)] +pub struct RichResponse { + #[serde(rename = "type")] + pub response_type: Option, + pub results: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct RichResult { + #[serde(rename = "type")] + pub result_type: Option, + pub subtype: Option, + #[serde(flatten)] + pub data: serde_json::Value, +} + +impl RichResponse { + pub fn format(&self) -> Option { + let results = self.results.as_ref()?; + let first = results.first()?; + first.format() + } +} + +impl RichResult { + pub fn format(&self) -> Option { + match self.subtype.as_deref()? { + "weather" => self.format_weather(), + "stock" => self.format_stock(), + "currency" => self.format_currency(), + "cryptocurrency" => self.format_crypto(), + "calculator" => self.format_calculator(), + "unit_conversion" => self.format_unit_conversion(), + "definitions" => self.format_definition(), + subtype => Some(format!( + "{}\n{}", + subtype, + serde_json::to_string_pretty(&self.data).unwrap_or_default() + )), + } + } + + fn format_weather(&self) -> Option { + let mut output = String::new(); + + if let Some(weather) = self.data.get("weather") { + if let Some(location) = weather.get("location") { + let name = location + .get("name") + .and_then(|value| value.as_str()) + .unwrap_or("Unknown"); + let state = location + .get("state") + .and_then(|value| value.as_str()) + .unwrap_or(""); + output.push_str(&format!("Weather for {}, {}\n\n", name, state)); + } + + if let Some(current) = weather.get("current_weather") { + output.push_str("Current Conditions:\n"); + if let Some(temp_c) = current.get("temp").and_then(|value| value.as_f64()) { + output.push_str(&format!( + " Temperature: {:.0}°F\n", + temp_c * 9.0 / 5.0 + 32.0 + )); + } + if let Some(feels_c) = current.get("feels_like").and_then(|value| value.as_f64()) { + output.push_str(&format!( + " Feels like: {:.0}°F\n", + feels_c * 9.0 / 5.0 + 32.0 + )); + } + if let Some(desc) = current + .get("weather") + .and_then(|value| value.get("description")) + .and_then(|value| value.as_str()) + { + output.push_str(&format!(" Conditions: {}\n", desc)); + } + if let Some(humidity) = current.get("humidity") { + output.push_str(&format!(" Humidity: {}%\n", humidity)); + } + if let Some(wind_ms) = current + .get("wind") + .and_then(|value| value.get("speed")) + .and_then(|value| value.as_f64()) + { + output.push_str(&format!(" Wind: {:.0} mph\n", wind_ms * 2.237)); + } + output.push('\n'); + } + + if let Some(alerts) = weather.get("alerts").and_then(|value| value.as_array()) { + if !alerts.is_empty() { + output.push_str("Weather Alerts:\n"); + for alert in alerts.iter().take(3) { + if let Some(event) = alert.get("event").and_then(|value| value.as_str()) { + output.push_str(&format!(" - {}\n", event)); + if let Some(desc) = + alert.get("description").and_then(|value| value.as_str()) + { + let short_desc: String = desc.chars().take(200).collect(); + output.push_str(&format!( + " {}{}\n", + short_desc, + if desc.len() > 200 { "..." } else { "" } + )); + } + } + } + output.push('\n'); + } + } + + if let Some(daily) = weather.get("daily").and_then(|value| value.as_array()) { + output.push_str("Forecast:\n"); + for (idx, day) in daily.iter().take(5).enumerate() { + let day_name = day + .get("date_i18n") + .and_then(|value| value.as_str()) + .map(|value| value.to_string()) + .unwrap_or_else(|| match idx { + 0 => "Today".to_string(), + 1 => "Tomorrow".to_string(), + _ => format!("Day {}", idx + 1), + }); + + let high = day + .get("temperature") + .and_then(|value| value.get("max")) + .and_then(|value| value.as_f64()) + .map(|value| format!("{:.0}°F", value * 9.0 / 5.0 + 32.0)) + .unwrap_or_default(); + let low = day + .get("temperature") + .and_then(|value| value.get("min")) + .and_then(|value| value.as_f64()) + .map(|value| format!("{:.0}°F", value * 9.0 / 5.0 + 32.0)) + .unwrap_or_default(); + let desc = day + .get("weather") + .and_then(|value| value.get("description")) + .and_then(|value| value.as_str()) + .unwrap_or(""); + + output.push_str(&format!( + " {} - High: {}, Low: {} - {}\n", + day_name, high, low, desc + )); + } + } + } else { + output.push_str(&serde_json::to_string_pretty(&self.data).unwrap_or_default()); + } + + if output.is_empty() { + None + } else { + Some(output) + } + } + + fn format_stock(&self) -> Option { + let mut output = String::from("Stock:\n\n"); + + if let Some(symbol) = self.data.get("symbol").and_then(|value| value.as_str()) { + output.push_str(&format!("Symbol: {}\n", symbol)); + } + if let Some(name) = self.data.get("name").and_then(|value| value.as_str()) { + output.push_str(&format!("Name: {}\n", name)); + } + if let Some(price) = self.data.get("price") { + output.push_str(&format!("Price: ${}\n", price)); + } + if let Some(change) = self.data.get("change") { + output.push_str(&format!("Change: {}\n", change)); + } + if let Some(change_pct) = self.data.get("change_percent") { + output.push_str(&format!("Change %: {}%\n", change_pct)); + } + + Some(output) + } + + fn format_currency(&self) -> Option { + Some(format!( + "Currency Conversion:\n\n{}", + serde_json::to_string_pretty(&self.data).unwrap_or_default() + )) + } + + fn format_crypto(&self) -> Option { + let mut output = String::from("Cryptocurrency:\n\n"); + + if let Some(name) = self.data.get("name").and_then(|value| value.as_str()) { + output.push_str(&format!("Name: {}\n", name)); + } + if let Some(symbol) = self.data.get("symbol").and_then(|value| value.as_str()) { + output.push_str(&format!("Symbol: {}\n", symbol)); + } + if let Some(price) = self.data.get("price") { + output.push_str(&format!("Price: ${}\n", price)); + } + if let Some(change) = self.data.get("change_24h") { + output.push_str(&format!("24h Change: {}%\n", change)); + } + + Some(output) + } + + fn format_calculator(&self) -> Option { + self.data.get("result").map(|result| match result.as_str() { + Some(value) => format!("Calculator: {}", value), + None => format!("Calculator: {}", result), + }) + } + + fn format_unit_conversion(&self) -> Option { + let result = self.data.get("result").and_then(|value| value.as_str())?; + Some(format!("Unit Conversion:\n{}", result)) + } + + fn format_definition(&self) -> Option { + let mut output = String::from("Definition:\n\n"); + + if let Some(word) = self.data.get("word").and_then(|value| value.as_str()) { + output.push_str(&format!("{}\n", word)); + } + if let Some(definitions) = self + .data + .get("definitions") + .and_then(|value| value.as_array()) + { + for (idx, definition) in definitions.iter().take(3).enumerate() { + if let Some(text) = definition + .get("definition") + .and_then(|value| value.as_str()) + { + output.push_str(&format!("{}. {}\n", idx + 1, text)); + } + } + } + + Some(output) + } +} + +impl SearchResponse { + pub fn format_results(&self) -> String { + let mut output = String::new(); + + if let Some(query) = &self.query { + if let Some(altered) = &query.altered { + if query.original.as_ref() != Some(altered) { + output.push_str(&format!("Showing results for: {}\n\n", altered)); + } + } + } + + if let Some(rich) = &self.rich_data { + if let Some(formatted) = rich.format() { + output.push_str(&formatted); + output.push_str("\n\n---\n\n"); + } + } + + if let Some(summary) = &self.summary_text { + output.push_str("AI Summary:\n"); + output.push_str(summary); + output.push_str("\n\n---\n\n"); + } + + if let Some(infobox) = &self.infobox { + if let Some(title) = &infobox.title { + output.push_str(title); + output.push('\n'); + if let Some(desc) = infobox.long_desc.as_ref().or(infobox.description.as_ref()) { + output.push_str(desc); + output.push_str("\n\n"); + } + } + } + + if let Some(faq) = &self.faq { + if let Some(results) = &faq.results { + if !results.is_empty() { + output.push_str("FAQ:\n\n"); + for item in results.iter().take(3) { + output.push_str(&format!("Q: {}\nA: {}\n\n", item.question, item.answer)); + } + } + } + } + + if let Some(web) = &self.web { + if let Some(results) = &web.results { + if !results.is_empty() { + output.push_str("Search Results:\n\n"); + for (idx, result) in results.iter().take(5).enumerate() { + let age = result + .age + .as_deref() + .map(|value| format!(" ({})", value)) + .unwrap_or_default(); + output.push_str(&format!( + "{}. {}{}\n URL: {}\n {}\n", + idx + 1, + result.title, + age, + result.url, + result.description.as_deref().unwrap_or("") + )); + if let Some(extra_snippets) = &result.extra_snippets { + for snippet in extra_snippets.iter().take(2) { + output.push_str(&format!(" > {}\n", snippet)); + } + } + output.push('\n'); + } + } + } + } + + if let Some(news) = &self.news { + if let Some(results) = &news.results { + if !results.is_empty() { + output.push_str("Recent News:\n\n"); + for (idx, result) in results.iter().take(3).enumerate() { + let age = result + .age + .as_deref() + .map(|value| format!(" ({})", value)) + .unwrap_or_default(); + output.push_str(&format!( + "{}. {}{}\n URL: {}\n {}\n\n", + idx + 1, + result.title, + age, + result.url, + result.description.as_deref().unwrap_or("") + )); + } + } + } + } + + if let Some(discussions) = &self.discussions { + if let Some(results) = &discussions.results { + if !results.is_empty() { + output.push_str("Discussions:\n\n"); + for result in results.iter().take(2) { + output.push_str(&format!("- {}\n {}\n\n", result.title, result.url)); + } + } + } + } + + if output.is_empty() { + "No results found.".to_string() + } else { + output + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn summarizer_extract_text_combines_token_items() { + let response = SummarizerSearchResponse { + status: Some("complete".to_string()), + summary: Some(vec![ + SummaryItem { + item_type: "token".to_string(), + data: Some(json!("hello ")), + }, + SummaryItem { + item_type: "enum_item".to_string(), + data: Some(json!({"ignored": true})), + }, + SummaryItem { + item_type: "token".to_string(), + data: Some(json!("world")), + }, + ]), + }; + + assert_eq!(response.extract_text().as_deref(), Some("hello world")); + } + + #[test] + fn format_results_includes_rich_summary_and_result_sections() { + let response = SearchResponse { + query: Some(QueryInfo { + original: Some("weathr austin".to_string()), + altered: Some("weather austin".to_string()), + }), + web: Some(WebResults { + results_type: None, + results: Some(vec![SearchResult { + title: "Austin forecast".to_string(), + url: "https://example.com/weather".to_string(), + description: Some("Sunny all week".to_string()), + age: Some("2h".to_string()), + extra_snippets: Some(vec!["High of 75F".to_string()]), + }]), + }), + news: Some(NewsResults { + results_type: None, + results: Some(vec![NewsResult { + title: "Austin weather update".to_string(), + url: "https://example.com/news".to_string(), + description: Some("Cold front incoming".to_string()), + age: Some("1h".to_string()), + }]), + }), + faq: Some(FaqResults { + results: Some(vec![FaqResult { + question: "Will it rain?".to_string(), + answer: "No rain expected today.".to_string(), + title: None, + url: None, + }]), + }), + discussions: Some(DiscussionResults { + results: Some(vec![DiscussionResult { + title: "Austin weather thread".to_string(), + url: "https://example.com/discuss".to_string(), + description: None, + }]), + }), + infobox: Some(Infobox { + infobox_type: None, + title: Some("Austin, TX".to_string()), + description: Some("Current weather conditions".to_string()), + long_desc: None, + }), + summarizer: None, + rich: None, + summary_text: Some("Warm and sunny today.".to_string()), + rich_data: Some(RichResponse { + response_type: None, + results: Some(vec![RichResult { + result_type: Some("rich_result".to_string()), + subtype: Some("calculator".to_string()), + data: json!({"result": "72 + 3 = 75"}), + }]), + }), + response_type: None, + }; + + let formatted = response.format_results(); + + assert!(formatted.contains("Showing results for: weather austin")); + assert!(formatted.contains("Calculator: 72 + 3 = 75")); + assert!(formatted.contains("AI Summary:")); + assert!(formatted.contains("Search Results:")); + assert!(formatted.contains("Recent News:")); + assert!(formatted.contains("Discussions:")); + } +} diff --git a/src/db.rs b/src/db.rs index 18429983..ddbe68b8 100644 --- a/src/db.rs +++ b/src/db.rs @@ -24,10 +24,10 @@ use crate::models::platform_password_reset::{ NewPlatformPasswordResetRequest, PlatformPasswordResetError, PlatformPasswordResetRequest, }; use crate::models::platform_users::{NewPlatformUser, PlatformUser, PlatformUserError}; -use crate::models::project_settings::OAuthSettings; use crate::models::project_settings::{ EmailSettings, NewProjectSetting, ProjectSetting, ProjectSettingError, SettingCategory, }; +use crate::models::project_settings::{OAuthSettings, PushSettings}; use crate::models::responses::{ AssistantMessage, Conversation, NewAssistantMessage, NewConversation, NewReasoningItem, NewResponse, NewToolCall, NewToolOutput, NewUserInstruction, NewUserMessage, RawThreadMessage, @@ -390,6 +390,14 @@ pub trait DBConnection { settings: OAuthSettings, ) -> Result; + fn get_project_push_settings(&self, project_id: i32) -> Result, DBError>; + + fn update_project_push_settings( + &self, + project_id: i32, + settings: PushSettings, + ) -> Result; + // Platform email verification methods fn create_platform_email_verification( &self, @@ -635,7 +643,6 @@ impl DBConnection for PostgresConnection { } fn get_user_by_uuid(&self, uuid: Uuid) -> Result { - debug!("Getting user by UUID"); let conn = &mut self.db.get().map_err(|_| DBError::ConnectionError)?; let result = User::get_by_uuid(conn, uuid)?.ok_or(DBError::UserNotFound); if let Err(ref e) = result { @@ -1634,6 +1641,36 @@ impl DBConnection for PostgresConnection { } } + fn get_project_push_settings(&self, project_id: i32) -> Result, DBError> { + debug!("Getting project push settings"); + let settings = self.get_project_settings(project_id, SettingCategory::Push)?; + + match settings { + Some(s) => s.get_push_settings().map(Some).map_err(DBError::from), + None => Ok(None), + } + } + + fn update_project_push_settings( + &self, + project_id: i32, + settings: PushSettings, + ) -> Result { + debug!("Updating project push settings"); + let new_settings = NewProjectSetting::new_push_settings(project_id, settings)?; + let conn = &mut self.db.get().map_err(|_| DBError::ConnectionError)?; + + if let Some(mut existing) = + ProjectSetting::get_by_project_and_category(conn, project_id, SettingCategory::Push)? + { + existing.settings = new_settings.settings; + existing.update(conn)?; + Ok(existing) + } else { + new_settings.insert(conn).map_err(DBError::from) + } + } + // Platform email verification implementations fn create_platform_email_verification( &self, @@ -2418,7 +2455,6 @@ impl DBConnection for PostgresConnection { after: Option, order: &str, ) -> Result, DBError> { - debug!("Getting conversation context messages"); let conn = &mut self.db.get().map_err(|_| DBError::ConnectionError)?; RawThreadMessage::get_conversation_context(conn, conversation_id, limit, after, order) .map_err(DBError::from) diff --git a/src/encrypt.rs b/src/encrypt.rs index 8f2cd0e9..30f8039b 100644 --- a/src/encrypt.rs +++ b/src/encrypt.rs @@ -32,7 +32,6 @@ pub enum EncryptError { } pub async fn encrypt_with_key(encryption_key: &SecretKey, bytes: &[u8]) -> Vec { - tracing::debug!("Entering encrypt_with_key"); let cipher = Aes256Gcm::new_from_slice(&encryption_key.secret_bytes()).expect("should convert"); // Generate a random 96-bit nonce @@ -46,13 +45,10 @@ pub async fn encrypt_with_key(encryption_key: &SecretKey, bytes: &[u8]) -> Vec Result, EncryptError> { - tracing::trace!("Entering decrypt_with_key"); - if bytes.len() < 12 { tracing::error!( "Decrypt failed: Input too short (length {}), minimum 12 bytes required", @@ -81,7 +77,6 @@ pub fn decrypt_with_key(encryption_key: &SecretKey, bytes: &[u8]) -> Result, next: Next, ) -> impl IntoResponse { - tracing::debug!("Entering validate_jwt"); let token = match req .headers() .get(header::AUTHORIZATION) @@ -430,8 +429,6 @@ pub async fn validate_jwt( None => return ApiError::InvalidJwt.into_response(), }; - tracing::trace!("Validating JWT"); - let claims = match validate_token(&token, &data, USER_ACCESS) { Ok(claims) => claims, Err(_) => return ApiError::InvalidJwt.into_response(), @@ -454,7 +451,6 @@ pub async fn validate_jwt( }; req.extensions_mut().insert(user); - tracing::debug!("Exiting validate_jwt"); next.run(req).await } @@ -463,7 +459,6 @@ pub async fn validate_platform_jwt( mut req: Request, next: Next, ) -> impl IntoResponse { - tracing::debug!("Entering validate_platform_jwt"); let token = match req .headers() .get(header::AUTHORIZATION) @@ -474,8 +469,6 @@ pub async fn validate_platform_jwt( None => return ApiError::InvalidJwt.into_response(), }; - tracing::trace!("Validating platform JWT"); - let claims = match validate_token(&token, &data, PLATFORM_ACCESS) { Ok(claims) => claims, Err(_) => return ApiError::InvalidJwt.into_response(), @@ -498,7 +491,6 @@ pub async fn validate_platform_jwt( }; req.extensions_mut().insert(platform_user); - tracing::debug!("Exiting validate_platform_jwt"); next.run(req).await } @@ -511,8 +503,6 @@ pub(crate) fn validate_token( let es256k = Es256k::::new(data.config.jwt_keys.secp.clone()); let public_key = data.config.jwt_keys.public_key(); - tracing::trace!("Attempting to validate ES256K token"); - // First parse the token with the correct type let parsed_token = match UntrustedToken::new(original_token) { Ok(token) => token, @@ -525,8 +515,6 @@ pub(crate) fn validate_token( // Deserialize claims first let token: Token = match es256k.validator(&public_key).validate(&parsed_token) { Ok(token) => { - tracing::trace!("ES256K signature validation successful"); - // Only validate expiration, not maturity let time_options = TimeOptions::default(); if let Err(e) = token.claims().validate_expiration(&time_options) { diff --git a/src/main.rs b/src/main.rs index 3063fa53..27849ece 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,12 +14,15 @@ use crate::models::account_deletion::{AccountDeletionError, NewAccountDeletionRe use crate::models::password_reset::NewPasswordResetRequest; use crate::models::platform_password_reset::NewPlatformPasswordResetRequest; use crate::models::platform_users::PlatformUser; +use crate::push::worker::start_push_worker; use crate::sqs::SqsEventPublisher; +use crate::web::agent::start_schedule_worker; use crate::web::openai_auth::validate_openai_auth; use crate::web::platform_login_routes; use crate::web::{ - conversations_routes, document_routes, health_routes_with_state, instructions_routes, - login_routes, oauth_routes, openai_routes, protected_routes, responses_routes, + agent_routes, conversations_routes, document_routes, health_routes_with_state, + instructions_routes, login_routes, oauth_routes, openai_routes, protected_routes, push_routes, + rag_routes, responses_routes, }; use crate::{attestation_routes::SessionState, web::platform_routes}; @@ -85,6 +88,8 @@ mod oauth; mod os_flags; mod private_key; mod proxy_config; +mod push; +mod rag; mod sqs; mod tokens; mod web; @@ -408,6 +413,7 @@ pub struct AppState { aws_credential_manager: Arc>>, enclave_key: Vec, proxy_router: Arc, + rag_cache: Arc>, resend_api_key: Option, ephemeral_keys: Arc>>, session_states: Arc>>, @@ -726,6 +732,7 @@ impl AppStateBuilder { aws_credential_manager, enclave_key, proxy_router, + rag_cache: Arc::new(tokio::sync::Mutex::new(rag::RagCache::default())), resend_api_key: self.resend_api_key, ephemeral_keys: Arc::new(RwLock::new(HashMap::new())), session_states: Arc::new(tokio::sync::RwLock::new(HashMap::new())), @@ -900,8 +907,6 @@ impl AppState { derivation_path: Option<&str>, seed_phrase_derivation_path: Option<&str>, ) -> Result { - info!("Getting user key for UUID: {}", user_uuid); - if let Some(path) = derivation_path { debug!("Using BIP-32 derivation path: {}", path); } @@ -911,13 +916,9 @@ impl AppState { } let user = self.get_user(user_uuid).await?; - debug!("User retrieved successfully"); let encrypted_seed = match user.get_seed_encrypted().await { - Some(es) => { - debug!("Found existing encrypted seed for user"); - es - } + Some(es) => es, None => { // create seed if not already exists info!( @@ -932,7 +933,6 @@ impl AppState { } }; - debug!("Decrypting user seed and deriving key"); let user_secret_key = decrypt_user_seed_to_key( self.enclave_key.clone(), encrypted_seed, @@ -940,7 +940,6 @@ impl AppState { seed_phrase_derivation_path, )?; - info!("Successfully derived key for user: {}", user_uuid); Ok(user_secret_key) } @@ -1698,7 +1697,17 @@ impl AppState { project_id: i32, key_name: &str, ) -> Result, Error> { - // Get the encrypted secret + Ok(self + .get_project_secret_bytes(project_id, key_name) + .await? + .map(|bytes| general_purpose::STANDARD.encode(bytes))) + } + + pub async fn get_project_secret_bytes( + &self, + project_id: i32, + key_name: &str, + ) -> Result>, Error> { let secret = match self .db .get_org_project_secret_by_key_name_and_project(key_name, project_id)? @@ -1707,15 +1716,24 @@ impl AppState { None => return Ok(None), }; - // Decrypt the secret using the enclave key let secret_key = SecretKey::from_slice(&self.enclave_key) .map_err(|e| Error::EncryptionError(e.to_string()))?; let decrypted_bytes = decrypt_with_key(&secret_key, &secret.secret_enc) .map_err(|e| Error::EncryptionError(e.to_string()))?; - // Always return base64 encoded bytes - Ok(Some(general_purpose::STANDARD.encode(&decrypted_bytes))) + Ok(Some(decrypted_bytes)) + } + + pub async fn get_project_secret_string( + &self, + project_id: i32, + key_name: &str, + ) -> Result, Error> { + let secret = self.get_project_secret_bytes(project_id, key_name).await?; + secret + .map(|bytes| String::from_utf8(bytes).map_err(|_| Error::SecretParsingError)) + .transpose() } pub async fn get_project_resend_api_key( @@ -2754,6 +2772,9 @@ async fn main() -> Result<(), Error> { ) .await?; + start_push_worker(app_state.clone()); + start_schedule_worker(app_state.clone()); + let cors = CorsLayer::new() // allow all method types .allow_methods(Any) @@ -2786,6 +2807,18 @@ async fn main() -> Result<(), Error> { document_routes(app_state.clone()) .route_layer(from_fn_with_state(app_state.clone(), validate_jwt)), ) + .merge( + rag_routes(app_state.clone()) + .route_layer(from_fn_with_state(app_state.clone(), validate_jwt)), + ) + .merge( + agent_routes(app_state.clone()) + .route_layer(from_fn_with_state(app_state.clone(), validate_jwt)), + ) + .merge( + push_routes(app_state.clone()) + .route_layer(from_fn_with_state(app_state.clone(), validate_jwt)), + ) .merge(attestation_routes::router(app_state.clone())) .merge(oauth_routes(app_state.clone())) .merge(platform_login_routes(app_state.clone())) diff --git a/src/models/agent_schedule_runs.rs b/src/models/agent_schedule_runs.rs new file mode 100644 index 00000000..cbba01e1 --- /dev/null +++ b/src/models/agent_schedule_runs.rs @@ -0,0 +1,364 @@ +use crate::models::schema::agent_schedule_runs; +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use diesel::sql_query; +use diesel::sql_types::{BigInt, Int4, Nullable, Text}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use uuid::Uuid; + +pub const AGENT_SCHEDULE_RUN_STATUS_PENDING: &str = "pending"; +pub const AGENT_SCHEDULE_RUN_STATUS_RETRY: &str = "retry"; +pub const AGENT_SCHEDULE_RUN_STATUS_COMPLETED: &str = "completed"; +pub const AGENT_SCHEDULE_RUN_STATUS_FAILED: &str = "failed"; +pub const AGENT_SCHEDULE_RUN_STATUS_CANCELLED: &str = "cancelled"; +pub const AGENT_SCHEDULE_RUN_STATUS_EXPIRED: &str = "expired"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentScheduleRunWriteResult { + Updated, + LostLease, +} + +impl AgentScheduleRunWriteResult { + fn from_updated_rows(updated_rows: usize) -> Self { + if updated_rows == 0 { + Self::LostLease + } else { + Self::Updated + } + } + + pub fn was_applied(self) -> bool { + matches!(self, Self::Updated) + } +} + +#[derive(Error, Debug)] +pub enum AgentScheduleRunError { + #[error("Database error: {0}")] + DatabaseError(#[from] diesel::result::Error), +} + +#[derive(Queryable, Identifiable, QueryableByName, Clone, Debug, Serialize, Deserialize)] +#[diesel(table_name = agent_schedule_runs)] +pub struct AgentScheduleRun { + pub id: i64, + pub uuid: Uuid, + pub schedule_id: i64, + pub user_id: Uuid, + pub agent_id: i64, + pub scheduled_for: DateTime, + pub stale_after_at: DateTime, + pub status: String, + pub attempt_count: i32, + pub next_attempt_at: DateTime, + pub lease_owner: Option, + pub lease_expires_at: Option>, + pub started_at: Option>, + pub first_output_at: Option>, + pub first_message_id: Option, + pub output_count: i32, + pub notification_enqueued_at: Option>, + pub completed_at: Option>, + pub last_error: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl AgentScheduleRun { + pub fn get_by_id( + conn: &mut PgConnection, + lookup_id: i64, + ) -> Result, AgentScheduleRunError> { + agent_schedule_runs::table + .filter(agent_schedule_runs::id.eq(lookup_id)) + .first::(conn) + .optional() + .map_err(AgentScheduleRunError::DatabaseError) + } + + pub fn lease_pending( + conn: &mut PgConnection, + limit: i64, + lease_owner: &str, + lease_seconds: i32, + ) -> Result, AgentScheduleRunError> { + let query = r#" + WITH candidates AS ( + SELECT r.id + FROM agent_schedule_runs r + WHERE r.next_attempt_at <= NOW() + AND ( + (r.status IN ('pending', 'retry') + AND (r.lease_expires_at IS NULL OR r.lease_expires_at < NOW())) + OR (r.status = 'leased' + AND (r.lease_expires_at IS NULL OR r.lease_expires_at < NOW())) + ) + ORDER BY r.next_attempt_at ASC, r.id ASC + FOR UPDATE OF r SKIP LOCKED + LIMIT $1 + ) + UPDATE agent_schedule_runs r + SET status = 'leased', + lease_owner = $2, + lease_expires_at = NOW() + ($3 * INTERVAL '1 second'), + started_at = COALESCE(r.started_at, NOW()), + updated_at = NOW() + FROM candidates + WHERE r.id = candidates.id + RETURNING r.* + "#; + + sql_query(query) + .bind::(limit) + .bind::(lease_owner) + .bind::(lease_seconds) + .get_results::(conn) + .map_err(AgentScheduleRunError::DatabaseError) + } + + pub fn renew_lease( + conn: &mut PgConnection, + lookup_id: i64, + expected_lease_owner: &str, + lease_seconds: i32, + ) -> Result { + let query = r#" + UPDATE agent_schedule_runs + SET lease_expires_at = NOW() + ($3 * INTERVAL '1 second'), + updated_at = NOW() + WHERE id = $1 + AND status = 'leased' + AND lease_owner = $2 + "#; + + sql_query(query) + .bind::(lookup_id) + .bind::(expected_lease_owner) + .bind::(lease_seconds) + .execute(conn) + .map(AgentScheduleRunWriteResult::from_updated_rows) + .map_err(AgentScheduleRunError::DatabaseError) + } + + pub fn record_output( + conn: &mut PgConnection, + lookup_id: i64, + expected_lease_owner: &str, + first_message_id: Uuid, + ) -> Result { + let query = r#" + UPDATE agent_schedule_runs + SET first_output_at = COALESCE(first_output_at, NOW()), + first_message_id = COALESCE(first_message_id, $3), + output_count = output_count + 1, + updated_at = NOW() + WHERE id = $1 + AND status = 'leased' + AND lease_owner = $2 + "#; + + sql_query(query) + .bind::(lookup_id) + .bind::(expected_lease_owner) + .bind::(first_message_id) + .execute(conn) + .map(AgentScheduleRunWriteResult::from_updated_rows) + .map_err(AgentScheduleRunError::DatabaseError) + } + + pub fn mark_retry( + conn: &mut PgConnection, + lookup_id: i64, + expected_lease_owner: &str, + last_error: Option<&str>, + retry_after_seconds: i32, + ) -> Result { + let query = r#" + UPDATE agent_schedule_runs + SET status = 'retry', + attempt_count = attempt_count + 1, + last_error = $3, + next_attempt_at = NOW() + ($4 * INTERVAL '1 second'), + lease_owner = NULL, + lease_expires_at = NULL, + updated_at = NOW() + WHERE id = $1 + AND status = 'leased' + AND lease_owner = $2 + "#; + + sql_query(query) + .bind::(lookup_id) + .bind::(expected_lease_owner) + .bind::, _>(last_error) + .bind::(retry_after_seconds) + .execute(conn) + .map(AgentScheduleRunWriteResult::from_updated_rows) + .map_err(AgentScheduleRunError::DatabaseError) + } + + pub fn mark_completed( + conn: &mut PgConnection, + lookup_id: i64, + expected_lease_owner: &str, + notification_enqueued: bool, + last_error: Option<&str>, + ) -> Result { + diesel::update( + agent_schedule_runs::table + .filter(agent_schedule_runs::id.eq(lookup_id)) + .filter(agent_schedule_runs::status.eq("leased")) + .filter( + agent_schedule_runs::lease_owner.eq(Some(expected_lease_owner.to_string())), + ), + ) + .set(( + agent_schedule_runs::status.eq(AGENT_SCHEDULE_RUN_STATUS_COMPLETED), + agent_schedule_runs::attempt_count.eq(agent_schedule_runs::attempt_count + 1), + agent_schedule_runs::last_error.eq(last_error), + agent_schedule_runs::notification_enqueued_at.eq(if notification_enqueued { + Some(Utc::now()) + } else { + None + }), + agent_schedule_runs::completed_at.eq(diesel::dsl::now), + agent_schedule_runs::lease_owner.eq::>(None), + agent_schedule_runs::lease_expires_at.eq::>>(None), + agent_schedule_runs::updated_at.eq(diesel::dsl::now), + )) + .execute(conn) + .map(AgentScheduleRunWriteResult::from_updated_rows) + .map_err(AgentScheduleRunError::DatabaseError) + } + + pub fn mark_failed( + conn: &mut PgConnection, + lookup_id: i64, + expected_lease_owner: &str, + last_error: Option<&str>, + ) -> Result { + diesel::update( + agent_schedule_runs::table + .filter(agent_schedule_runs::id.eq(lookup_id)) + .filter(agent_schedule_runs::status.eq("leased")) + .filter( + agent_schedule_runs::lease_owner.eq(Some(expected_lease_owner.to_string())), + ), + ) + .set(( + agent_schedule_runs::status.eq(AGENT_SCHEDULE_RUN_STATUS_FAILED), + agent_schedule_runs::attempt_count.eq(agent_schedule_runs::attempt_count + 1), + agent_schedule_runs::last_error.eq(last_error), + agent_schedule_runs::completed_at.eq(diesel::dsl::now), + agent_schedule_runs::lease_owner.eq::>(None), + agent_schedule_runs::lease_expires_at.eq::>>(None), + agent_schedule_runs::updated_at.eq(diesel::dsl::now), + )) + .execute(conn) + .map(AgentScheduleRunWriteResult::from_updated_rows) + .map_err(AgentScheduleRunError::DatabaseError) + } + + pub fn mark_expired( + conn: &mut PgConnection, + lookup_id: i64, + expected_lease_owner: &str, + last_error: Option<&str>, + ) -> Result { + diesel::update( + agent_schedule_runs::table + .filter(agent_schedule_runs::id.eq(lookup_id)) + .filter(agent_schedule_runs::status.eq("leased")) + .filter( + agent_schedule_runs::lease_owner.eq(Some(expected_lease_owner.to_string())), + ), + ) + .set(( + agent_schedule_runs::status.eq(AGENT_SCHEDULE_RUN_STATUS_EXPIRED), + agent_schedule_runs::attempt_count.eq(agent_schedule_runs::attempt_count + 1), + agent_schedule_runs::last_error.eq(last_error), + agent_schedule_runs::completed_at.eq(diesel::dsl::now), + agent_schedule_runs::lease_owner.eq::>(None), + agent_schedule_runs::lease_expires_at.eq::>>(None), + agent_schedule_runs::updated_at.eq(diesel::dsl::now), + )) + .execute(conn) + .map(AgentScheduleRunWriteResult::from_updated_rows) + .map_err(AgentScheduleRunError::DatabaseError) + } + + pub fn mark_cancelled( + conn: &mut PgConnection, + lookup_id: i64, + expected_lease_owner: &str, + last_error: Option<&str>, + ) -> Result { + diesel::update( + agent_schedule_runs::table + .filter(agent_schedule_runs::id.eq(lookup_id)) + .filter(agent_schedule_runs::status.eq("leased")) + .filter( + agent_schedule_runs::lease_owner.eq(Some(expected_lease_owner.to_string())), + ), + ) + .set(( + agent_schedule_runs::status.eq(AGENT_SCHEDULE_RUN_STATUS_CANCELLED), + agent_schedule_runs::last_error.eq(last_error), + agent_schedule_runs::completed_at.eq(diesel::dsl::now), + agent_schedule_runs::lease_owner.eq::>(None), + agent_schedule_runs::lease_expires_at.eq::>>(None), + agent_schedule_runs::updated_at.eq(diesel::dsl::now), + )) + .execute(conn) + .map(AgentScheduleRunWriteResult::from_updated_rows) + .map_err(AgentScheduleRunError::DatabaseError) + } + + pub fn cancel_unstarted_for_schedule( + conn: &mut PgConnection, + lookup_schedule_id: i64, + ) -> Result { + diesel::update( + agent_schedule_runs::table + .filter(agent_schedule_runs::schedule_id.eq(lookup_schedule_id)) + .filter(agent_schedule_runs::status.eq_any(vec![ + AGENT_SCHEDULE_RUN_STATUS_PENDING, + AGENT_SCHEDULE_RUN_STATUS_RETRY, + ])), + ) + .set(( + agent_schedule_runs::status.eq(AGENT_SCHEDULE_RUN_STATUS_CANCELLED), + agent_schedule_runs::completed_at.eq(diesel::dsl::now), + agent_schedule_runs::updated_at.eq(diesel::dsl::now), + )) + .execute(conn) + .map_err(AgentScheduleRunError::DatabaseError) + } +} + +#[derive(Insertable, Debug, Clone)] +#[diesel(table_name = agent_schedule_runs)] +pub struct NewAgentScheduleRun { + pub uuid: Uuid, + pub schedule_id: i64, + pub user_id: Uuid, + pub agent_id: i64, + pub scheduled_for: DateTime, + pub stale_after_at: DateTime, + pub status: String, + pub next_attempt_at: DateTime, +} + +impl NewAgentScheduleRun { + pub fn insert( + &self, + conn: &mut PgConnection, + ) -> Result { + diesel::insert_into(agent_schedule_runs::table) + .values(self) + .get_result::(conn) + .map_err(AgentScheduleRunError::DatabaseError) + } +} diff --git a/src/models/agent_schedules.rs b/src/models/agent_schedules.rs new file mode 100644 index 00000000..360a7295 --- /dev/null +++ b/src/models/agent_schedules.rs @@ -0,0 +1,342 @@ +use crate::models::schema::agent_schedules; +use chrono::{DateTime, NaiveDate, NaiveTime, Utc}; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; +use uuid::Uuid; + +pub const SCHEDULE_KIND_ONE_OFF: &str = "one_off"; +pub const SCHEDULE_KIND_RECURRING: &str = "recurring"; + +pub const RECURRENCE_TYPE_INTERVAL: &str = "interval"; +pub const RECURRENCE_TYPE_DAILY: &str = "daily"; +pub const RECURRENCE_TYPE_WEEKLY: &str = "weekly"; + +pub const SCHEDULE_TIMEZONE_MODE_FOLLOW_USER: &str = "follow_user"; +pub const SCHEDULE_TIMEZONE_MODE_FIXED: &str = "fixed"; + +pub const SCHEDULE_STATUS_ACTIVE: &str = "active"; +pub const SCHEDULE_STATUS_COMPLETED: &str = "completed"; +pub const SCHEDULE_STATUS_CANCELLED: &str = "cancelled"; + +#[derive(Error, Debug)] +pub enum AgentScheduleError { + #[error("Database error: {0}")] + DatabaseError(#[from] diesel::result::Error), + #[error("Invalid schedule spec: {0}")] + InvalidSpec(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ScheduleWeekday { + Monday, + Tuesday, + Wednesday, + Thursday, + Friday, + Saturday, + Sunday, +} + +impl ScheduleWeekday { + pub fn as_str(&self) -> &'static str { + match self { + Self::Monday => "monday", + Self::Tuesday => "tuesday", + Self::Wednesday => "wednesday", + Self::Thursday => "thursday", + Self::Friday => "friday", + Self::Saturday => "saturday", + Self::Sunday => "sunday", + } + } + + pub fn to_chrono(&self) -> chrono::Weekday { + match self { + Self::Monday => chrono::Weekday::Mon, + Self::Tuesday => chrono::Weekday::Tue, + Self::Wednesday => chrono::Weekday::Wed, + Self::Thursday => chrono::Weekday::Thu, + Self::Friday => chrono::Weekday::Fri, + Self::Saturday => chrono::Weekday::Sat, + Self::Sunday => chrono::Weekday::Sun, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ScheduleIntervalUnit { + Hours, +} + +impl ScheduleIntervalUnit { + pub fn as_str(&self) -> &'static str { + match self { + Self::Hours => "hours", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ScheduleSpec { + OneOff { + local_date: String, + local_time: String, + }, + Interval { + every_n: u32, + interval_unit: ScheduleIntervalUnit, + }, + Daily { + local_time: String, + }, + Weekly { + local_time: String, + weekdays: Vec, + }, +} + +impl ScheduleSpec { + pub fn schedule_kind(&self) -> &'static str { + match self { + Self::OneOff { .. } => SCHEDULE_KIND_ONE_OFF, + Self::Interval { .. } | Self::Daily { .. } | Self::Weekly { .. } => { + SCHEDULE_KIND_RECURRING + } + } + } + + pub fn recurrence_type(&self) -> Option<&'static str> { + match self { + Self::OneOff { .. } => None, + Self::Interval { .. } => Some(RECURRENCE_TYPE_INTERVAL), + Self::Daily { .. } => Some(RECURRENCE_TYPE_DAILY), + Self::Weekly { .. } => Some(RECURRENCE_TYPE_WEEKLY), + } + } + + pub fn validate(&self) -> Result<(), AgentScheduleError> { + match self { + Self::OneOff { + local_date, + local_time, + } => { + parse_local_date(local_date)?; + parse_local_time(local_time)?; + Ok(()) + } + Self::Interval { + every_n, + interval_unit, + } => { + if *every_n == 0 { + return Err(AgentScheduleError::InvalidSpec( + "interval every_n must be greater than zero".to_string(), + )); + } + + match interval_unit { + ScheduleIntervalUnit::Hours => Ok(()), + } + } + Self::Daily { local_time } => { + parse_local_time(local_time)?; + Ok(()) + } + Self::Weekly { + local_time, + weekdays, + } => { + parse_local_time(local_time)?; + if weekdays.is_empty() { + return Err(AgentScheduleError::InvalidSpec( + "weekly schedules require at least one weekday".to_string(), + )); + } + + Ok(()) + } + } + } + + pub fn summary(&self) -> String { + match self { + Self::OneOff { + local_date, + local_time, + } => format!("once on {} at {}", local_date, local_time), + Self::Interval { + every_n, + interval_unit, + } => format!("every {} {}", every_n, interval_unit.as_str()), + Self::Daily { local_time } => format!("every day at {}", local_time), + Self::Weekly { + local_time, + weekdays, + } => { + let day_list = weekdays + .iter() + .map(ScheduleWeekday::as_str) + .collect::>() + .join(", "); + format!("every {} at {}", day_list, local_time) + } + } + } + + pub fn to_value(&self) -> Result { + serde_json::to_value(self) + .map_err(|e| AgentScheduleError::InvalidSpec(format!("serialize schedule spec: {e}"))) + } + + pub fn from_value(value: &Value) -> Result { + serde_json::from_value(value.clone()) + .map_err(|e| AgentScheduleError::InvalidSpec(format!("parse schedule spec: {e}"))) + } +} + +pub fn parse_local_time(value: &str) -> Result { + NaiveTime::parse_from_str(value.trim(), "%H:%M").map_err(|_| { + AgentScheduleError::InvalidSpec(format!( + "invalid local_time '{value}'. Use 24-hour HH:MM format" + )) + }) +} + +pub fn parse_local_date(value: &str) -> Result { + NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d").map_err(|_| { + AgentScheduleError::InvalidSpec(format!( + "invalid local_date '{value}'. Use YYYY-MM-DD format" + )) + }) +} + +#[derive(Queryable, Identifiable, AsChangeset, Clone, Debug, Serialize, Deserialize)] +#[diesel(table_name = agent_schedules)] +pub struct AgentSchedule { + pub id: i64, + pub uuid: Uuid, + pub user_id: Uuid, + pub agent_id: i64, + pub description: String, + pub instruction_enc: Vec, + pub schedule_kind: String, + pub recurrence_type: Option, + pub schedule_spec: Value, + pub timezone_mode: String, + pub resolved_timezone: String, + pub fixed_timezone: Option, + pub stale_after_minutes: i32, + pub status: String, + pub next_scheduled_for: Option>, + pub last_scheduled_for: Option>, + pub last_run_at: Option>, + pub run_count: i32, + pub cancelled_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl AgentSchedule { + pub fn get_by_id( + conn: &mut PgConnection, + lookup_id: i64, + ) -> Result, AgentScheduleError> { + agent_schedules::table + .filter(agent_schedules::id.eq(lookup_id)) + .first::(conn) + .optional() + .map_err(AgentScheduleError::DatabaseError) + } + + pub fn get_by_uuid_and_agent( + conn: &mut PgConnection, + lookup_uuid: Uuid, + lookup_user_id: Uuid, + lookup_agent_id: i64, + ) -> Result, AgentScheduleError> { + agent_schedules::table + .filter(agent_schedules::uuid.eq(lookup_uuid)) + .filter(agent_schedules::user_id.eq(lookup_user_id)) + .filter(agent_schedules::agent_id.eq(lookup_agent_id)) + .first::(conn) + .optional() + .map_err(AgentScheduleError::DatabaseError) + } + + pub fn list_by_agent( + conn: &mut PgConnection, + lookup_user_id: Uuid, + lookup_agent_id: i64, + status_filter: Option<&str>, + ) -> Result, AgentScheduleError> { + let mut query = agent_schedules::table + .filter(agent_schedules::user_id.eq(lookup_user_id)) + .filter(agent_schedules::agent_id.eq(lookup_agent_id)) + .into_boxed(); + + if let Some(status_filter) = status_filter { + query = query.filter(agent_schedules::status.eq(status_filter)); + } + + query + .order(( + agent_schedules::next_scheduled_for.asc().nulls_last(), + agent_schedules::created_at.desc(), + )) + .load::(conn) + .map_err(AgentScheduleError::DatabaseError) + } + + pub fn list_active_follow_user_for_user( + conn: &mut PgConnection, + lookup_user_id: Uuid, + ) -> Result, AgentScheduleError> { + agent_schedules::table + .filter(agent_schedules::user_id.eq(lookup_user_id)) + .filter(agent_schedules::status.eq(SCHEDULE_STATUS_ACTIVE)) + .filter(agent_schedules::timezone_mode.eq(SCHEDULE_TIMEZONE_MODE_FOLLOW_USER)) + .load::(conn) + .map_err(AgentScheduleError::DatabaseError) + } + + pub fn spec(&self) -> Result { + ScheduleSpec::from_value(&self.schedule_spec) + } +} + +#[derive(Insertable, Debug, Clone)] +#[diesel(table_name = agent_schedules)] +pub struct NewAgentSchedule { + pub uuid: Uuid, + pub user_id: Uuid, + pub agent_id: i64, + pub description: String, + pub instruction_enc: Vec, + pub schedule_kind: String, + pub recurrence_type: Option, + pub schedule_spec: Value, + pub timezone_mode: String, + pub resolved_timezone: String, + pub fixed_timezone: Option, + pub stale_after_minutes: i32, + pub status: String, + pub next_scheduled_for: Option>, + pub last_scheduled_for: Option>, + pub last_run_at: Option>, + pub run_count: i32, + pub cancelled_at: Option>, +} + +impl NewAgentSchedule { + pub fn insert(&self, conn: &mut PgConnection) -> Result { + diesel::insert_into(agent_schedules::table) + .values(self) + .get_result::(conn) + .map_err(AgentScheduleError::DatabaseError) + } +} diff --git a/src/models/agents.rs b/src/models/agents.rs new file mode 100644 index 00000000..d9ab2295 --- /dev/null +++ b/src/models/agents.rs @@ -0,0 +1,200 @@ +use crate::models::responses::Conversation; +use crate::models::schema::agents; +use crate::models::schema::conversations; +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use uuid::Uuid; + +pub const AGENT_KIND_MAIN: &str = "main"; +pub const AGENT_KIND_SUBAGENT: &str = "subagent"; +pub const AGENT_CREATED_BY_USER: &str = "user"; +pub const AGENT_CREATED_BY_AGENT: &str = "agent"; + +#[derive(Error, Debug)] +pub enum AgentError { + #[error("Database error: {0}")] + DatabaseError(#[from] diesel::result::Error), +} + +#[derive(Queryable, Identifiable, AsChangeset, Serialize, Deserialize, Clone, Debug)] +#[diesel(table_name = agents)] +pub struct Agent { + pub id: i64, + pub uuid: Uuid, + pub user_id: Uuid, + pub conversation_id: i64, + pub kind: String, + pub parent_agent_id: Option, + pub display_name_enc: Option>, + pub purpose_enc: Option>, + pub created_by: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl Agent { + pub fn get_by_id(conn: &mut PgConnection, lookup_id: i64) -> Result, AgentError> { + agents::table + .filter(agents::id.eq(lookup_id)) + .first::(conn) + .optional() + .map_err(AgentError::DatabaseError) + } + + pub fn get_main_for_user( + conn: &mut PgConnection, + lookup_user_id: Uuid, + ) -> Result, AgentError> { + agents::table + .filter(agents::user_id.eq(lookup_user_id)) + .filter(agents::kind.eq(AGENT_KIND_MAIN)) + .first::(conn) + .optional() + .map_err(AgentError::DatabaseError) + } + + pub fn get_by_uuid_and_user( + conn: &mut PgConnection, + lookup_uuid: Uuid, + lookup_user_id: Uuid, + ) -> Result, AgentError> { + agents::table + .filter(agents::uuid.eq(lookup_uuid)) + .filter(agents::user_id.eq(lookup_user_id)) + .first::(conn) + .optional() + .map_err(AgentError::DatabaseError) + } + + pub fn get_by_conversation_id_and_user( + conn: &mut PgConnection, + lookup_conversation_id: i64, + lookup_user_id: Uuid, + ) -> Result, AgentError> { + agents::table + .filter(agents::conversation_id.eq(lookup_conversation_id)) + .filter(agents::user_id.eq(lookup_user_id)) + .first::(conn) + .optional() + .map_err(AgentError::DatabaseError) + } + + pub fn list_subagents_for_user( + conn: &mut PgConnection, + lookup_user_id: Uuid, + ) -> Result, AgentError> { + agents::table + .filter(agents::user_id.eq(lookup_user_id)) + .filter(agents::kind.eq(AGENT_KIND_SUBAGENT)) + .order((agents::created_at.desc(), agents::id.desc())) + .load::(conn) + .map_err(AgentError::DatabaseError) + } + + pub fn list_subagents_for_user_paginated( + conn: &mut PgConnection, + lookup_user_id: Uuid, + limit: i64, + after: Option, + order: &str, + created_by_filter: Option<&str>, + ) -> Result, AgentError> { + let mut query = agents::table + .inner_join(conversations::table) + .filter(agents::user_id.eq(lookup_user_id)) + .filter(agents::kind.eq(AGENT_KIND_SUBAGENT)) + .into_boxed(); + + if let Some(created_by_filter) = created_by_filter { + query = query.filter(agents::created_by.eq(created_by_filter)); + } + + if let Some(after_uuid) = after { + let mut cursor_query = agents::table + .inner_join(conversations::table) + .filter(agents::user_id.eq(lookup_user_id)) + .filter(agents::kind.eq(AGENT_KIND_SUBAGENT)) + .filter(agents::uuid.eq(after_uuid)) + .into_boxed(); + + if let Some(created_by_filter) = created_by_filter { + cursor_query = cursor_query.filter(agents::created_by.eq(created_by_filter)); + } + + let cursor_subagent = cursor_query + .select((conversations::updated_at, agents::id)) + .first::<(DateTime, i64)>(conn) + .optional() + .map_err(AgentError::DatabaseError)?; + + if let Some((updated_at, id)) = cursor_subagent { + if order == "desc" { + query = query.filter( + conversations::updated_at + .lt(updated_at) + .or(conversations::updated_at + .eq(updated_at) + .and(agents::id.lt(id))), + ); + } else { + query = query.filter( + conversations::updated_at + .gt(updated_at) + .or(conversations::updated_at + .eq(updated_at) + .and(agents::id.gt(id))), + ); + } + } + } + + if order == "desc" { + query = query.order((conversations::updated_at.desc(), agents::id.desc())); + } else { + query = query.order((conversations::updated_at.asc(), agents::id.asc())); + } + + query + .limit(limit) + .load::<(Agent, Conversation)>(conn) + .map_err(AgentError::DatabaseError) + } + + pub fn delete_by_uuid_and_user( + conn: &mut PgConnection, + lookup_uuid: Uuid, + lookup_user_id: Uuid, + ) -> Result { + diesel::delete( + agents::table + .filter(agents::uuid.eq(lookup_uuid)) + .filter(agents::user_id.eq(lookup_user_id)), + ) + .execute(conn) + .map_err(AgentError::DatabaseError) + } +} + +#[derive(Insertable, Debug, Clone)] +#[diesel(table_name = agents)] +pub struct NewAgent { + pub uuid: Uuid, + pub user_id: Uuid, + pub conversation_id: i64, + pub kind: String, + pub parent_agent_id: Option, + pub display_name_enc: Option>, + pub purpose_enc: Option>, + pub created_by: String, +} + +impl NewAgent { + pub fn insert(&self, conn: &mut PgConnection) -> Result { + diesel::insert_into(agents::table) + .values(self) + .get_result::(conn) + .map_err(AgentError::DatabaseError) + } +} diff --git a/src/models/conversation_summaries.rs b/src/models/conversation_summaries.rs new file mode 100644 index 00000000..674d9654 --- /dev/null +++ b/src/models/conversation_summaries.rs @@ -0,0 +1,107 @@ +use crate::models::schema::conversation_summaries; +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use uuid::Uuid; + +#[derive(Error, Debug)] +pub enum ConversationSummaryError { + #[error("Database error: {0}")] + DatabaseError(#[from] diesel::result::Error), +} + +#[derive(Queryable, Identifiable, AsChangeset, Serialize, Deserialize, Clone, Debug)] +#[diesel(table_name = conversation_summaries)] +pub struct ConversationSummary { + pub id: i64, + pub uuid: Uuid, + pub user_id: Uuid, + pub conversation_id: i64, + pub from_created_at: DateTime, + pub to_created_at: DateTime, + pub message_count: i32, + pub content_enc: Vec, + pub content_tokens: i32, + pub embedding_enc: Option>, + pub previous_summary_id: Option, + pub created_at: DateTime, +} + +impl ConversationSummary { + pub fn get_latest_for_conversation( + conn: &mut PgConnection, + lookup_user_id: Uuid, + lookup_conversation_id: i64, + ) -> Result, ConversationSummaryError> { + conversation_summaries::table + .filter(conversation_summaries::user_id.eq(lookup_user_id)) + .filter(conversation_summaries::conversation_id.eq(lookup_conversation_id)) + .order(( + conversation_summaries::created_at.desc(), + conversation_summaries::id.desc(), + )) + .first::(conn) + .optional() + .map_err(ConversationSummaryError::DatabaseError) + } + + pub fn list_for_conversation( + conn: &mut PgConnection, + lookup_user_id: Uuid, + lookup_conversation_id: i64, + limit: i64, + ) -> Result, ConversationSummaryError> { + conversation_summaries::table + .filter(conversation_summaries::user_id.eq(lookup_user_id)) + .filter(conversation_summaries::conversation_id.eq(lookup_conversation_id)) + .order(( + conversation_summaries::created_at.desc(), + conversation_summaries::id.desc(), + )) + .limit(limit) + .load::(conn) + .map_err(ConversationSummaryError::DatabaseError) + } + + pub fn delete_all_for_conversation( + conn: &mut PgConnection, + lookup_user_id: Uuid, + lookup_conversation_id: i64, + ) -> Result { + diesel::delete( + conversation_summaries::table + .filter(conversation_summaries::user_id.eq(lookup_user_id)) + .filter(conversation_summaries::conversation_id.eq(lookup_conversation_id)), + ) + .execute(conn) + .map_err(ConversationSummaryError::DatabaseError) + } +} + +#[derive(Insertable, Debug, Clone)] +#[diesel(table_name = conversation_summaries)] +pub struct NewConversationSummary { + pub uuid: Uuid, + pub user_id: Uuid, + pub conversation_id: i64, + pub from_created_at: DateTime, + pub to_created_at: DateTime, + pub message_count: i32, + pub content_enc: Vec, + pub content_tokens: i32, + pub embedding_enc: Option>, + pub previous_summary_id: Option, +} + +impl NewConversationSummary { + pub fn insert( + &self, + conn: &mut PgConnection, + ) -> Result { + diesel::insert_into(conversation_summaries::table) + .values(self) + .get_result::(conn) + .map_err(ConversationSummaryError::DatabaseError) + } +} diff --git a/src/models/memory_blocks.rs b/src/models/memory_blocks.rs new file mode 100644 index 00000000..744d46f2 --- /dev/null +++ b/src/models/memory_blocks.rs @@ -0,0 +1,109 @@ +use crate::models::schema::memory_blocks; +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use uuid::Uuid; + +pub const MEMORY_BLOCK_LABEL_PERSONA: &str = "persona"; +pub const MEMORY_BLOCK_LABEL_HUMAN: &str = "human"; + +#[derive(Error, Debug)] +pub enum MemoryBlockError { + #[error("Database error: {0}")] + DatabaseError(#[from] diesel::result::Error), +} + +#[derive(Queryable, Identifiable, AsChangeset, Serialize, Deserialize, Clone, Debug)] +#[diesel(table_name = memory_blocks)] +pub struct MemoryBlock { + pub id: i64, + pub uuid: Uuid, + pub user_id: Uuid, + pub label: String, + pub value_enc: Vec, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl MemoryBlock { + pub fn get_by_user_and_label( + conn: &mut PgConnection, + lookup_user_id: Uuid, + lookup_label: &str, + ) -> Result, MemoryBlockError> { + memory_blocks::table + .filter(memory_blocks::user_id.eq(lookup_user_id)) + .filter(memory_blocks::label.eq(lookup_label)) + .first::(conn) + .optional() + .map_err(MemoryBlockError::DatabaseError) + } + + pub fn get_all_for_user( + conn: &mut PgConnection, + lookup_user_id: Uuid, + ) -> Result, MemoryBlockError> { + memory_blocks::table + .filter(memory_blocks::user_id.eq(lookup_user_id)) + .order(memory_blocks::label.asc()) + .load::(conn) + .map_err(MemoryBlockError::DatabaseError) + } + + pub fn delete_all_for_user( + conn: &mut PgConnection, + lookup_user_id: Uuid, + ) -> Result { + diesel::delete(memory_blocks::table.filter(memory_blocks::user_id.eq(lookup_user_id))) + .execute(conn) + .map_err(MemoryBlockError::DatabaseError) + } + + pub fn delete_by_user_and_label( + conn: &mut PgConnection, + lookup_user_id: Uuid, + lookup_label: &str, + ) -> Result { + diesel::delete( + memory_blocks::table + .filter(memory_blocks::user_id.eq(lookup_user_id)) + .filter(memory_blocks::label.eq(lookup_label)), + ) + .execute(conn) + .map_err(MemoryBlockError::DatabaseError) + } +} + +#[derive(Insertable, Debug, Clone)] +#[diesel(table_name = memory_blocks)] +pub struct NewMemoryBlock { + pub uuid: Uuid, + pub user_id: Uuid, + pub label: String, + pub value_enc: Vec, +} + +impl NewMemoryBlock { + pub fn new(user_id: Uuid, label: impl Into, value_enc: Vec) -> Self { + NewMemoryBlock { + uuid: Uuid::new_v4(), + user_id, + label: label.into(), + value_enc, + } + } + + pub fn insert_or_update( + &self, + conn: &mut PgConnection, + ) -> Result { + diesel::insert_into(memory_blocks::table) + .values(self) + .on_conflict((memory_blocks::user_id, memory_blocks::label)) + .do_update() + .set(memory_blocks::value_enc.eq(self.value_enc.clone())) + .get_result::(conn) + .map_err(MemoryBlockError::DatabaseError) + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 39416e19..2907e6a6 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,7 +1,14 @@ pub mod account_deletion; +pub mod agent_schedule_runs; +pub mod agent_schedules; +pub mod agents; +pub mod conversation_summaries; pub mod email_verification; pub mod enclave_secrets; pub mod invite_codes; +pub mod memory_blocks; +pub mod notification_deliveries; +pub mod notification_events; pub mod oauth; pub mod org_memberships; pub mod org_project_secrets; @@ -13,9 +20,12 @@ pub mod platform_invite_codes; pub mod platform_password_reset; pub mod platform_users; pub mod project_settings; +pub mod push_devices; pub mod responses; pub mod schema; pub mod token_usage; pub mod user_api_keys; +pub mod user_embeddings; pub mod user_kv; +pub mod user_preferences; pub mod users; diff --git a/src/models/notification_deliveries.rs b/src/models/notification_deliveries.rs new file mode 100644 index 00000000..47ce7838 --- /dev/null +++ b/src/models/notification_deliveries.rs @@ -0,0 +1,280 @@ +use crate::models::schema::notification_deliveries; +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use diesel::sql_query; +use diesel::sql_types::{BigInt, Int4, Text}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +pub const NOTIFICATION_DELIVERY_STATUS_SENT: &str = "sent"; +pub const NOTIFICATION_DELIVERY_STATUS_FAILED: &str = "failed"; +pub const NOTIFICATION_DELIVERY_STATUS_INVALID_TOKEN: &str = "invalid_token"; +pub const NOTIFICATION_DELIVERY_STATUS_CANCELLED: &str = "cancelled"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotificationDeliveryWriteResult { + Updated, + LostLease, +} + +impl NotificationDeliveryWriteResult { + fn from_updated_rows(updated_rows: usize) -> Self { + if updated_rows == 0 { + Self::LostLease + } else { + Self::Updated + } + } + + pub fn was_applied(self) -> bool { + matches!(self, Self::Updated) + } +} + +#[derive(Error, Debug)] +pub enum NotificationDeliveryError { + #[error("Database error: {0}")] + DatabaseError(#[from] diesel::result::Error), +} + +#[derive(Queryable, Identifiable, QueryableByName, Clone, Debug, Serialize, Deserialize)] +#[diesel(table_name = notification_deliveries)] +pub struct NotificationDelivery { + pub id: i64, + pub event_id: i64, + pub push_device_id: i64, + pub status: String, + pub attempt_count: i32, + pub next_attempt_at: DateTime, + pub lease_owner: Option, + pub lease_expires_at: Option>, + pub provider_message_id: Option, + pub provider_status_code: Option, + pub last_error: Option, + pub sent_at: Option>, + pub invalidated_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl NotificationDelivery { + pub fn get_by_id( + conn: &mut PgConnection, + lookup_id: i64, + ) -> Result, NotificationDeliveryError> { + notification_deliveries::table + .filter(notification_deliveries::id.eq(lookup_id)) + .first::(conn) + .optional() + .map_err(NotificationDeliveryError::DatabaseError) + } + + pub fn lease_pending( + conn: &mut PgConnection, + limit: i64, + lease_owner: &str, + lease_seconds: i32, + ) -> Result, NotificationDeliveryError> { + let query = r#" + WITH candidates AS ( + SELECT d.id + FROM notification_deliveries d + WHERE d.next_attempt_at <= NOW() + AND ( + (d.status IN ('pending', 'retry') + AND (d.lease_expires_at IS NULL OR d.lease_expires_at < NOW())) + OR (d.status = 'leased' + AND (d.lease_expires_at IS NULL OR d.lease_expires_at < NOW())) + ) + ORDER BY d.next_attempt_at ASC, d.id ASC + FOR UPDATE OF d SKIP LOCKED + LIMIT $1 + ) + UPDATE notification_deliveries d + SET status = 'leased', + lease_owner = $2, + lease_expires_at = NOW() + ($3 * INTERVAL '1 second'), + updated_at = NOW() + FROM candidates + WHERE d.id = candidates.id + RETURNING d.* + "#; + + sql_query(query) + .bind::(limit) + .bind::(lease_owner) + .bind::(lease_seconds) + .get_results::(conn) + .map_err(NotificationDeliveryError::DatabaseError) + } + + pub fn mark_sent( + conn: &mut PgConnection, + lookup_id: i64, + expected_lease_owner: &str, + provider_message_id: Option<&str>, + provider_status_code: Option, + ) -> Result { + diesel::update( + notification_deliveries::table + .filter(notification_deliveries::id.eq(lookup_id)) + .filter(notification_deliveries::status.eq("leased")) + .filter( + notification_deliveries::lease_owner.eq(Some(expected_lease_owner.to_string())), + ), + ) + .set(( + notification_deliveries::status.eq(NOTIFICATION_DELIVERY_STATUS_SENT), + notification_deliveries::attempt_count.eq(notification_deliveries::attempt_count + 1), + notification_deliveries::provider_message_id.eq(provider_message_id), + notification_deliveries::provider_status_code.eq(provider_status_code), + notification_deliveries::last_error.eq::>(None), + notification_deliveries::sent_at.eq(diesel::dsl::now), + notification_deliveries::lease_owner.eq::>(None), + notification_deliveries::lease_expires_at.eq::>>(None), + notification_deliveries::updated_at.eq(diesel::dsl::now), + )) + .execute(conn) + .map(NotificationDeliveryWriteResult::from_updated_rows) + .map_err(NotificationDeliveryError::DatabaseError) + } + + pub fn mark_retry( + conn: &mut PgConnection, + lookup_id: i64, + expected_lease_owner: &str, + provider_status_code: Option, + last_error: Option<&str>, + retry_after_seconds: i32, + ) -> Result { + let query = r#" + UPDATE notification_deliveries + SET status = 'retry', + attempt_count = attempt_count + 1, + provider_status_code = $3, + last_error = $4, + next_attempt_at = NOW() + ($5 * INTERVAL '1 second'), + lease_owner = NULL, + lease_expires_at = NULL, + updated_at = NOW() + WHERE id = $1 + AND lease_owner = $2 + AND status = 'leased' + "#; + + sql_query(query) + .bind::(lookup_id) + .bind::(expected_lease_owner) + .bind::, _>(provider_status_code) + .bind::, _>(last_error) + .bind::(retry_after_seconds) + .execute(conn) + .map(NotificationDeliveryWriteResult::from_updated_rows) + .map_err(NotificationDeliveryError::DatabaseError) + } + + pub fn mark_failed( + conn: &mut PgConnection, + lookup_id: i64, + expected_lease_owner: &str, + provider_status_code: Option, + last_error: Option<&str>, + ) -> Result { + diesel::update( + notification_deliveries::table + .filter(notification_deliveries::id.eq(lookup_id)) + .filter(notification_deliveries::status.eq("leased")) + .filter( + notification_deliveries::lease_owner.eq(Some(expected_lease_owner.to_string())), + ), + ) + .set(( + notification_deliveries::status.eq(NOTIFICATION_DELIVERY_STATUS_FAILED), + notification_deliveries::attempt_count.eq(notification_deliveries::attempt_count + 1), + notification_deliveries::provider_status_code.eq(provider_status_code), + notification_deliveries::last_error.eq(last_error), + notification_deliveries::lease_owner.eq::>(None), + notification_deliveries::lease_expires_at.eq::>>(None), + notification_deliveries::updated_at.eq(diesel::dsl::now), + )) + .execute(conn) + .map(NotificationDeliveryWriteResult::from_updated_rows) + .map_err(NotificationDeliveryError::DatabaseError) + } + + pub fn mark_invalid_token( + conn: &mut PgConnection, + lookup_id: i64, + expected_lease_owner: &str, + provider_status_code: Option, + last_error: Option<&str>, + ) -> Result { + diesel::update( + notification_deliveries::table + .filter(notification_deliveries::id.eq(lookup_id)) + .filter(notification_deliveries::status.eq("leased")) + .filter( + notification_deliveries::lease_owner.eq(Some(expected_lease_owner.to_string())), + ), + ) + .set(( + notification_deliveries::status.eq(NOTIFICATION_DELIVERY_STATUS_INVALID_TOKEN), + notification_deliveries::attempt_count.eq(notification_deliveries::attempt_count + 1), + notification_deliveries::provider_status_code.eq(provider_status_code), + notification_deliveries::last_error.eq(last_error), + notification_deliveries::invalidated_at.eq(diesel::dsl::now), + notification_deliveries::lease_owner.eq::>(None), + notification_deliveries::lease_expires_at.eq::>>(None), + notification_deliveries::updated_at.eq(diesel::dsl::now), + )) + .execute(conn) + .map(NotificationDeliveryWriteResult::from_updated_rows) + .map_err(NotificationDeliveryError::DatabaseError) + } + + pub fn mark_cancelled( + conn: &mut PgConnection, + lookup_id: i64, + expected_lease_owner: &str, + last_error: Option<&str>, + ) -> Result { + diesel::update( + notification_deliveries::table + .filter(notification_deliveries::id.eq(lookup_id)) + .filter(notification_deliveries::status.eq("leased")) + .filter( + notification_deliveries::lease_owner.eq(Some(expected_lease_owner.to_string())), + ), + ) + .set(( + notification_deliveries::status.eq(NOTIFICATION_DELIVERY_STATUS_CANCELLED), + notification_deliveries::last_error.eq(last_error), + notification_deliveries::lease_owner.eq::>(None), + notification_deliveries::lease_expires_at.eq::>>(None), + notification_deliveries::updated_at.eq(diesel::dsl::now), + )) + .execute(conn) + .map(NotificationDeliveryWriteResult::from_updated_rows) + .map_err(NotificationDeliveryError::DatabaseError) + } +} + +#[derive(Insertable, Debug, Clone)] +#[diesel(table_name = notification_deliveries)] +pub struct NewNotificationDelivery { + pub event_id: i64, + pub push_device_id: i64, + pub next_attempt_at: DateTime, +} + +impl NewNotificationDelivery { + pub fn insert_many( + conn: &mut PgConnection, + new_deliveries: &[NewNotificationDelivery], + ) -> Result { + diesel::insert_into(notification_deliveries::table) + .values(new_deliveries) + .execute(conn) + .map_err(NotificationDeliveryError::DatabaseError) + } +} diff --git a/src/models/notification_events.rs b/src/models/notification_events.rs new file mode 100644 index 00000000..7b4492bd --- /dev/null +++ b/src/models/notification_events.rs @@ -0,0 +1,91 @@ +use crate::models::schema::notification_events; +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use uuid::Uuid; + +pub const NOTIFICATION_KIND_AGENT_MESSAGE: &str = "agent.message"; +pub const NOTIFICATION_DELIVERY_MODE_GENERIC: &str = "generic"; +pub const NOTIFICATION_DELIVERY_MODE_ENCRYPTED_PREVIEW: &str = "encrypted_preview"; +pub const NOTIFICATION_PRIORITY_NORMAL: &str = "normal"; +pub const NOTIFICATION_PRIORITY_HIGH: &str = "high"; + +#[derive(Error, Debug)] +pub enum NotificationEventError { + #[error("Database error: {0}")] + DatabaseError(#[from] diesel::result::Error), +} + +#[derive(Queryable, Identifiable, Clone, Debug, Serialize, Deserialize)] +#[diesel(table_name = notification_events)] +pub struct NotificationEvent { + pub id: i64, + pub uuid: Uuid, + pub project_id: i32, + pub user_id: Uuid, + pub kind: String, + pub delivery_mode: String, + pub priority: String, + pub collapse_key: Option, + pub fallback_title: String, + pub fallback_body: String, + pub payload_enc: Option>, + pub not_before_at: DateTime, + pub expires_at: Option>, + pub created_at: DateTime, + pub cancelled_at: Option>, +} + +impl NotificationEvent { + pub fn get_by_id( + conn: &mut PgConnection, + lookup_id: i64, + ) -> Result, NotificationEventError> { + notification_events::table + .filter(notification_events::id.eq(lookup_id)) + .first::(conn) + .optional() + .map_err(NotificationEventError::DatabaseError) + } + + pub fn cancel( + conn: &mut PgConnection, + lookup_id: i64, + ) -> Result, NotificationEventError> { + diesel::update(notification_events::table.filter(notification_events::id.eq(lookup_id))) + .set(notification_events::cancelled_at.eq(diesel::dsl::now)) + .get_result::(conn) + .optional() + .map_err(NotificationEventError::DatabaseError) + } +} + +#[derive(Insertable, Debug, Clone)] +#[diesel(table_name = notification_events, treat_none_as_default_value = true)] +pub struct NewNotificationEvent { + pub uuid: Uuid, + pub project_id: i32, + pub user_id: Uuid, + pub kind: String, + pub delivery_mode: String, + pub priority: String, + pub collapse_key: Option, + pub fallback_title: String, + pub fallback_body: String, + pub payload_enc: Option>, + pub not_before_at: Option>, + pub expires_at: Option>, +} + +impl NewNotificationEvent { + pub fn insert( + &self, + conn: &mut PgConnection, + ) -> Result { + diesel::insert_into(notification_events::table) + .values(self) + .get_result::(conn) + .map_err(NotificationEventError::DatabaseError) + } +} diff --git a/src/models/project_settings.rs b/src/models/project_settings.rs index cb8a7a43..7bd1f259 100644 --- a/src/models/project_settings.rs +++ b/src/models/project_settings.rs @@ -20,6 +20,7 @@ pub enum ProjectSettingError { pub enum SettingCategory { Email, OAuth, + Push, } impl SettingCategory { @@ -27,6 +28,7 @@ impl SettingCategory { match self { SettingCategory::Email => "email", SettingCategory::OAuth => "oauth", + SettingCategory::Push => "push", } } } @@ -74,6 +76,49 @@ pub struct OAuthSettings { pub apple_oauth_settings: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum PushEnvironment { + Dev, + #[default] + Prod, +} + +impl PushEnvironment { + pub fn as_str(&self) -> &'static str { + match self { + Self::Dev => "dev", + Self::Prod => "prod", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct IosPushSettings { + pub enabled: bool, + pub bundle_id: String, + pub apns_environment: PushEnvironment, + pub team_id: String, + pub key_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AndroidPushSettings { + pub enabled: bool, + pub firebase_project_id: String, + pub package_name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PushSettings { + #[serde(default)] + pub encrypted_preview_enabled: bool, + #[serde(default)] + pub ios: Option, + #[serde(default)] + pub android: Option, +} + #[derive(Queryable, Identifiable)] #[diesel(table_name = project_settings)] pub struct ProjectSetting { @@ -97,6 +142,11 @@ impl ProjectSetting { .map_err(ProjectSettingError::SerializationError) } + pub fn get_push_settings(&self) -> Result { + serde_json::from_value(self.settings.clone()) + .map_err(ProjectSettingError::SerializationError) + } + pub fn get_by_project_and_category( conn: &mut PgConnection, lookup_project_id: i32, @@ -159,6 +209,18 @@ impl NewProjectSetting { }) } + pub fn new_push_settings( + project_id: i32, + push_settings: PushSettings, + ) -> Result { + Ok(Self { + project_id, + category: SettingCategory::Push.as_str().to_string(), + settings: serde_json::to_value(push_settings) + .map_err(ProjectSettingError::SerializationError)?, + }) + } + pub fn insert(&self, conn: &mut PgConnection) -> Result { diesel::insert_into(project_settings::table) .values(self) diff --git a/src/models/push_devices.rs b/src/models/push_devices.rs new file mode 100644 index 00000000..1a7a57a0 --- /dev/null +++ b/src/models/push_devices.rs @@ -0,0 +1,240 @@ +use crate::models::schema::push_devices; +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use uuid::Uuid; + +pub const PUSH_PLATFORM_IOS: &str = "ios"; +pub const PUSH_PLATFORM_ANDROID: &str = "android"; +pub const PUSH_PROVIDER_APNS: &str = "apns"; +pub const PUSH_PROVIDER_FCM: &str = "fcm"; +pub const PUSH_ENV_DEV: &str = "dev"; +pub const PUSH_ENV_PROD: &str = "prod"; +pub const PUSH_KEY_ALGORITHM_P256_ECDH_V1: &str = "p256_ecdh_v1"; + +#[derive(Error, Debug)] +pub enum PushDeviceError { + #[error("Database error: {0}")] + DatabaseError(#[from] diesel::result::Error), +} + +#[derive(Queryable, Identifiable, Clone, Debug, Serialize, Deserialize)] +#[diesel(table_name = push_devices)] +pub struct PushDevice { + pub id: i64, + pub uuid: Uuid, + pub user_id: Uuid, + pub installation_id: Uuid, + pub platform: String, + pub provider: String, + pub environment: String, + pub app_id: String, + pub push_token_enc: Vec, + pub push_token_hash: Vec, + pub notification_public_key: Vec, + pub key_algorithm: String, + pub supports_encrypted_preview: bool, + pub supports_background_processing: bool, + pub last_seen_at: DateTime, + pub revoked_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl PushDevice { + pub fn get_by_id( + conn: &mut PgConnection, + lookup_id: i64, + ) -> Result, PushDeviceError> { + push_devices::table + .filter(push_devices::id.eq(lookup_id)) + .first::(conn) + .optional() + .map_err(PushDeviceError::DatabaseError) + } + + pub fn get_by_uuid_and_user( + conn: &mut PgConnection, + lookup_uuid: Uuid, + lookup_user_id: Uuid, + ) -> Result, PushDeviceError> { + push_devices::table + .filter(push_devices::uuid.eq(lookup_uuid)) + .filter(push_devices::user_id.eq(lookup_user_id)) + .first::(conn) + .optional() + .map_err(PushDeviceError::DatabaseError) + } + + pub fn get_by_installation_for_user( + conn: &mut PgConnection, + lookup_user_id: Uuid, + lookup_installation_id: Uuid, + lookup_environment: &str, + ) -> Result, PushDeviceError> { + if let Some(active_device) = push_devices::table + .filter(push_devices::user_id.eq(lookup_user_id)) + .filter(push_devices::installation_id.eq(lookup_installation_id)) + .filter(push_devices::environment.eq(lookup_environment)) + .filter(push_devices::revoked_at.is_null()) + .order(push_devices::updated_at.desc()) + .first::(conn) + .optional() + .map_err(PushDeviceError::DatabaseError)? + { + return Ok(Some(active_device)); + } + + push_devices::table + .filter(push_devices::user_id.eq(lookup_user_id)) + .filter(push_devices::installation_id.eq(lookup_installation_id)) + .filter(push_devices::environment.eq(lookup_environment)) + .order(push_devices::updated_at.desc()) + .first::(conn) + .optional() + .map_err(PushDeviceError::DatabaseError) + } + + pub fn get_by_installation( + conn: &mut PgConnection, + lookup_installation_id: Uuid, + lookup_environment: &str, + ) -> Result, PushDeviceError> { + push_devices::table + .filter(push_devices::installation_id.eq(lookup_installation_id)) + .filter(push_devices::environment.eq(lookup_environment)) + .filter(push_devices::revoked_at.is_null()) + .order(push_devices::updated_at.desc()) + .first::(conn) + .optional() + .map_err(PushDeviceError::DatabaseError) + } + + pub fn get_active_by_token_hash( + conn: &mut PgConnection, + lookup_provider: &str, + lookup_environment: &str, + lookup_token_hash: &[u8], + ) -> Result, PushDeviceError> { + push_devices::table + .filter(push_devices::provider.eq(lookup_provider)) + .filter(push_devices::environment.eq(lookup_environment)) + .filter(push_devices::push_token_hash.eq(lookup_token_hash)) + .filter(push_devices::revoked_at.is_null()) + .first::(conn) + .optional() + .map_err(PushDeviceError::DatabaseError) + } + + pub fn list_active_for_user( + conn: &mut PgConnection, + lookup_user_id: Uuid, + ) -> Result, PushDeviceError> { + push_devices::table + .filter(push_devices::user_id.eq(lookup_user_id)) + .filter(push_devices::revoked_at.is_null()) + .order((push_devices::last_seen_at.desc(), push_devices::id.desc())) + .load::(conn) + .map_err(PushDeviceError::DatabaseError) + } + + pub fn update(&self, conn: &mut PgConnection) -> Result<(), PushDeviceError> { + diesel::update(push_devices::table.filter(push_devices::id.eq(self.id))) + .set(( + push_devices::user_id.eq(self.user_id), + push_devices::installation_id.eq(self.installation_id), + push_devices::platform.eq(&self.platform), + push_devices::provider.eq(&self.provider), + push_devices::environment.eq(&self.environment), + push_devices::app_id.eq(&self.app_id), + push_devices::push_token_enc.eq(&self.push_token_enc), + push_devices::push_token_hash.eq(&self.push_token_hash), + push_devices::notification_public_key.eq(&self.notification_public_key), + push_devices::key_algorithm.eq(&self.key_algorithm), + push_devices::supports_encrypted_preview.eq(self.supports_encrypted_preview), + push_devices::supports_background_processing + .eq(self.supports_background_processing), + push_devices::last_seen_at.eq(self.last_seen_at), + push_devices::revoked_at.eq(self.revoked_at), + push_devices::updated_at.eq(diesel::dsl::now), + )) + .execute(conn) + .map(|_| ()) + .map_err(PushDeviceError::DatabaseError) + } + + pub fn revoke_by_id( + conn: &mut PgConnection, + lookup_id: i64, + ) -> Result, PushDeviceError> { + diesel::update(push_devices::table.filter(push_devices::id.eq(lookup_id))) + .set(( + push_devices::revoked_at.eq(diesel::dsl::now), + push_devices::updated_at.eq(diesel::dsl::now), + )) + .get_result::(conn) + .optional() + .map_err(PushDeviceError::DatabaseError) + } + + pub fn revoke_by_uuid_and_user( + conn: &mut PgConnection, + lookup_uuid: Uuid, + lookup_user_id: Uuid, + ) -> Result, PushDeviceError> { + diesel::update( + push_devices::table + .filter(push_devices::uuid.eq(lookup_uuid)) + .filter(push_devices::user_id.eq(lookup_user_id)), + ) + .set(( + push_devices::revoked_at.eq(diesel::dsl::now), + push_devices::updated_at.eq(diesel::dsl::now), + )) + .get_result::(conn) + .optional() + .map_err(PushDeviceError::DatabaseError) + } + + pub fn invalidate( + conn: &mut PgConnection, + lookup_id: i64, + ) -> Result, PushDeviceError> { + diesel::update(push_devices::table.filter(push_devices::id.eq(lookup_id))) + .set(( + push_devices::revoked_at.eq(diesel::dsl::now), + push_devices::updated_at.eq(diesel::dsl::now), + )) + .get_result::(conn) + .optional() + .map_err(PushDeviceError::DatabaseError) + } +} + +#[derive(Insertable, Debug, Clone)] +#[diesel(table_name = push_devices)] +pub struct NewPushDevice { + pub user_id: Uuid, + pub installation_id: Uuid, + pub platform: String, + pub provider: String, + pub environment: String, + pub app_id: String, + pub push_token_enc: Vec, + pub push_token_hash: Vec, + pub notification_public_key: Vec, + pub key_algorithm: String, + pub supports_encrypted_preview: bool, + pub supports_background_processing: bool, + pub last_seen_at: DateTime, +} + +impl NewPushDevice { + pub fn insert(&self, conn: &mut PgConnection) -> Result { + diesel::insert_into(push_devices::table) + .values(self) + .get_result::(conn) + .map_err(PushDeviceError::DatabaseError) + } +} diff --git a/src/models/responses.rs b/src/models/responses.rs index 5ed58b3f..0870082c 100644 --- a/src/models/responses.rs +++ b/src/models/responses.rs @@ -420,7 +420,9 @@ impl NewConversation { response_id: response_result.as_ref().map(|r| r.id), user_id, content_enc: first_message_content, + attachment_text_enc: None, prompt_tokens: first_message_tokens, + assistant_reaction: None, }; let user_message = new_message.insert(tx)?; @@ -435,6 +437,7 @@ impl NewConversation { completion_tokens: 0, status: "in_progress".to_string(), finish_reason: None, + user_reaction: None, }; placeholder_assistant.insert(tx)?; } @@ -461,6 +464,8 @@ pub struct UserMessage { pub prompt_tokens: i32, pub created_at: DateTime, pub updated_at: DateTime, + pub attachment_text_enc: Option>, + pub assistant_reaction: Option, } #[derive(Insertable, Debug)] @@ -472,6 +477,8 @@ pub struct NewUserMessage { pub user_id: Uuid, pub content_enc: Vec, pub prompt_tokens: i32, + pub attachment_text_enc: Option>, + pub assistant_reaction: Option, } impl UserMessage { @@ -505,6 +512,28 @@ impl UserMessage { }) } + pub fn set_assistant_reaction( + conn: &mut PgConnection, + message_uuid: Uuid, + user_id: Uuid, + reaction: Option, + ) -> Result { + diesel::update( + user_messages::table + .filter(user_messages::uuid.eq(message_uuid)) + .filter(user_messages::user_id.eq(user_id)), + ) + .set(( + user_messages::assistant_reaction.eq(reaction), + user_messages::updated_at.eq(diesel::dsl::now), + )) + .get_result(conn) + .map_err(|e| match e { + diesel::result::Error::NotFound => ResponsesError::UserMessageNotFound, + _ => ResponsesError::DatabaseError(e), + }) + } + // Note: status is now tracked on Response, not UserMessage pub fn delete_by_id_and_user( @@ -663,6 +692,7 @@ pub struct AssistantMessage { pub finish_reason: Option, pub created_at: DateTime, pub updated_at: DateTime, + pub user_reaction: Option, } #[derive(Insertable, Debug)] @@ -676,9 +706,25 @@ pub struct NewAssistantMessage { pub completion_tokens: i32, pub status: String, pub finish_reason: Option, + pub user_reaction: Option, } impl AssistantMessage { + pub fn get_by_uuid_and_user( + conn: &mut PgConnection, + message_uuid: Uuid, + user_id: Uuid, + ) -> Result { + assistant_messages::table + .filter(assistant_messages::uuid.eq(message_uuid)) + .filter(assistant_messages::user_id.eq(user_id)) + .first::(conn) + .map_err(|e| match e { + diesel::result::Error::NotFound => ResponsesError::AssistantMessageNotFound, + _ => ResponsesError::DatabaseError(e), + }) + } + pub fn update( conn: &mut PgConnection, message_uuid: Uuid, @@ -698,6 +744,28 @@ impl AssistantMessage { .get_result(conn) .map_err(ResponsesError::DatabaseError) } + + pub fn set_user_reaction( + conn: &mut PgConnection, + message_uuid: Uuid, + user_id: Uuid, + reaction: Option, + ) -> Result { + diesel::update( + assistant_messages::table + .filter(assistant_messages::uuid.eq(message_uuid)) + .filter(assistant_messages::user_id.eq(user_id)), + ) + .set(( + assistant_messages::user_reaction.eq(reaction), + assistant_messages::updated_at.eq(diesel::dsl::now), + )) + .get_result(conn) + .map_err(|e| match e { + diesel::result::Error::NotFound => ResponsesError::AssistantMessageNotFound, + _ => ResponsesError::DatabaseError(e), + }) + } } impl NewAssistantMessage { @@ -790,6 +858,10 @@ pub struct RawThreadMessage { pub uuid: Uuid, #[diesel(sql_type = diesel::sql_types::Nullable)] pub content_enc: Option>, + #[diesel(sql_type = diesel::sql_types::Nullable)] + pub attachment_text_enc: Option>, + #[diesel(sql_type = diesel::sql_types::Nullable)] + pub reaction: Option, #[diesel(sql_type = diesel::sql_types::Nullable)] pub status: Option, #[diesel(sql_type = diesel::sql_types::Timestamptz)] @@ -832,6 +904,8 @@ impl RawThreadMessage { um.id, um.uuid, um.content_enc, + um.attachment_text_enc, + um.assistant_reaction as reaction, 'completed'::text as status, um.created_at, r.model, @@ -851,6 +925,8 @@ impl RawThreadMessage { am.id, am.uuid, am.content_enc, + NULL::bytea as attachment_text_enc, + am.user_reaction as reaction, am.status, am.created_at, r.model, @@ -870,6 +946,8 @@ impl RawThreadMessage { tc.id, tc.uuid, tc.arguments_enc as content_enc, + NULL::bytea as attachment_text_enc, + NULL::text as reaction, 'completed'::text as status, tc.created_at, NULL::text as model, @@ -888,6 +966,8 @@ impl RawThreadMessage { tto.id, tto.uuid, tto.output_enc as content_enc, + NULL::bytea as attachment_text_enc, + NULL::text as reaction, 'completed'::text as status, tto.created_at, NULL::text as model, @@ -907,6 +987,8 @@ impl RawThreadMessage { ri.id, ri.uuid, ri.content_enc, + NULL::bytea as attachment_text_enc, + NULL::text as reaction, ri.status, ri.created_at, NULL::text as model, @@ -945,6 +1027,8 @@ impl RawThreadMessage { um.id, um.uuid, um.content_enc, + um.attachment_text_enc, + um.assistant_reaction as reaction, 'completed'::text as status, um.created_at, r.model, @@ -964,6 +1048,8 @@ impl RawThreadMessage { am.id, am.uuid, am.content_enc, + NULL::bytea as attachment_text_enc, + am.user_reaction as reaction, am.status, am.created_at, r.model, @@ -983,6 +1069,8 @@ impl RawThreadMessage { tc.id, tc.uuid, tc.arguments_enc as content_enc, + NULL::bytea as attachment_text_enc, + NULL::text as reaction, 'completed'::text as status, tc.created_at, NULL::text as model, @@ -1001,6 +1089,8 @@ impl RawThreadMessage { tto.id, tto.uuid, tto.output_enc as content_enc, + NULL::bytea as attachment_text_enc, + NULL::text as reaction, 'completed'::text as status, tto.created_at, NULL::text as model, @@ -1020,6 +1110,8 @@ impl RawThreadMessage { ri.id, ri.uuid, ri.content_enc, + NULL::bytea as attachment_text_enc, + NULL::text as reaction, ri.status, ri.created_at, NULL::text as model, @@ -1067,6 +1159,8 @@ impl RawThreadMessage { um.id, um.uuid, um.content_enc, + um.attachment_text_enc, + um.assistant_reaction as reaction, 'completed'::text as status, um.created_at, r.model, @@ -1086,6 +1180,8 @@ impl RawThreadMessage { am.id, am.uuid, am.content_enc, + NULL::bytea as attachment_text_enc, + am.user_reaction as reaction, am.status, am.created_at, r.model, @@ -1105,6 +1201,8 @@ impl RawThreadMessage { tc.id, tc.uuid, tc.arguments_enc as content_enc, + NULL::bytea as attachment_text_enc, + NULL::text as reaction, 'completed'::text as status, tc.created_at, NULL::text as model, @@ -1123,6 +1221,8 @@ impl RawThreadMessage { tto.id, tto.uuid, tto.output_enc as content_enc, + NULL::bytea as attachment_text_enc, + NULL::text as reaction, 'completed'::text as status, tto.created_at, NULL::text as model, @@ -1142,6 +1242,8 @@ impl RawThreadMessage { ri.id, ri.uuid, ri.content_enc, + NULL::bytea as attachment_text_enc, + NULL::text as reaction, ri.status, ri.created_at, NULL::text as model, @@ -1209,6 +1311,8 @@ impl RawThreadMessage { um.id, um.uuid, um.content_enc, + um.attachment_text_enc, + um.assistant_reaction as reaction, 'completed'::text as status, um.created_at, r.model, @@ -1228,6 +1332,8 @@ impl RawThreadMessage { am.id, am.uuid, am.content_enc, + NULL::bytea as attachment_text_enc, + am.user_reaction as reaction, am.status, am.created_at, r.model, @@ -1247,6 +1353,8 @@ impl RawThreadMessage { tc.id, tc.uuid, tc.arguments_enc as content_enc, + NULL::bytea as attachment_text_enc, + NULL::text as reaction, 'completed'::text as status, tc.created_at, NULL::text as model, @@ -1265,6 +1373,8 @@ impl RawThreadMessage { tto.id, tto.uuid, tto.output_enc as content_enc, + NULL::bytea as attachment_text_enc, + NULL::text as reaction, 'completed'::text as status, tto.created_at, NULL::text as model, @@ -1284,6 +1394,8 @@ impl RawThreadMessage { ri.id, ri.uuid, ri.content_enc, + NULL::bytea as attachment_text_enc, + NULL::text as reaction, ri.status, ri.created_at, NULL::text as model, diff --git a/src/models/schema.rs b/src/models/schema.rs index fffdd9a7..dda9d0cd 100644 --- a/src/models/schema.rs +++ b/src/models/schema.rs @@ -21,6 +21,74 @@ diesel::table! { } } +diesel::table! { + agent_schedule_runs (id) { + id -> Int8, + uuid -> Uuid, + schedule_id -> Int8, + user_id -> Uuid, + agent_id -> Int8, + scheduled_for -> Timestamptz, + stale_after_at -> Timestamptz, + status -> Text, + attempt_count -> Int4, + next_attempt_at -> Timestamptz, + lease_owner -> Nullable, + lease_expires_at -> Nullable, + started_at -> Nullable, + first_output_at -> Nullable, + first_message_id -> Nullable, + output_count -> Int4, + notification_enqueued_at -> Nullable, + completed_at -> Nullable, + last_error -> Nullable, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + +diesel::table! { + agent_schedules (id) { + id -> Int8, + uuid -> Uuid, + user_id -> Uuid, + agent_id -> Int8, + description -> Text, + instruction_enc -> Bytea, + schedule_kind -> Text, + recurrence_type -> Nullable, + schedule_spec -> Jsonb, + timezone_mode -> Text, + resolved_timezone -> Text, + fixed_timezone -> Nullable, + stale_after_minutes -> Int4, + status -> Text, + next_scheduled_for -> Nullable, + last_scheduled_for -> Nullable, + last_run_at -> Nullable, + run_count -> Int4, + cancelled_at -> Nullable, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + +diesel::table! { + agents (id) { + id -> Int8, + uuid -> Uuid, + user_id -> Uuid, + conversation_id -> Int8, + kind -> Text, + parent_agent_id -> Nullable, + display_name_enc -> Nullable, + purpose_enc -> Nullable, + created_by -> Text, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + diesel::table! { assistant_messages (id) { id -> Int8, @@ -34,6 +102,24 @@ diesel::table! { finish_reason -> Nullable, created_at -> Timestamptz, updated_at -> Timestamptz, + user_reaction -> Nullable, + } +} + +diesel::table! { + conversation_summaries (id) { + id -> Int8, + uuid -> Uuid, + user_id -> Uuid, + conversation_id -> Int8, + from_created_at -> Timestamptz, + to_created_at -> Timestamptz, + message_count -> Int4, + content_enc -> Bytea, + content_tokens -> Int4, + embedding_enc -> Nullable, + previous_summary_id -> Nullable, + created_at -> Timestamptz, } } @@ -82,6 +168,58 @@ diesel::table! { } } +diesel::table! { + memory_blocks (id) { + id -> Int8, + uuid -> Uuid, + user_id -> Uuid, + label -> Text, + value_enc -> Bytea, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + +diesel::table! { + notification_deliveries (id) { + id -> Int8, + event_id -> Int8, + push_device_id -> Int8, + status -> Text, + attempt_count -> Int4, + next_attempt_at -> Timestamptz, + lease_owner -> Nullable, + lease_expires_at -> Nullable, + provider_message_id -> Nullable, + provider_status_code -> Nullable, + last_error -> Nullable, + sent_at -> Nullable, + invalidated_at -> Nullable, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + +diesel::table! { + notification_events (id) { + id -> Int8, + uuid -> Uuid, + project_id -> Int4, + user_id -> Uuid, + kind -> Text, + delivery_mode -> Text, + priority -> Text, + collapse_key -> Nullable, + fallback_title -> Text, + fallback_body -> Text, + payload_enc -> Nullable, + not_before_at -> Timestamptz, + expires_at -> Nullable, + created_at -> Timestamptz, + cancelled_at -> Nullable, + } +} + diesel::table! { oauth_providers (id) { id -> Int4, @@ -210,6 +348,29 @@ diesel::table! { } } +diesel::table! { + push_devices (id) { + id -> Int8, + uuid -> Uuid, + user_id -> Uuid, + installation_id -> Uuid, + platform -> Text, + provider -> Text, + environment -> Text, + app_id -> Text, + push_token_enc -> Bytea, + push_token_hash -> Bytea, + notification_public_key -> Bytea, + key_algorithm -> Text, + supports_encrypted_preview -> Bool, + supports_background_processing -> Bool, + last_seen_at -> Timestamptz, + revoked_at -> Nullable, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + diesel::table! { reasoning_items (id) { id -> Int8, @@ -307,6 +468,27 @@ diesel::table! { } } +diesel::table! { + user_embeddings (id) { + id -> Int8, + uuid -> Uuid, + user_id -> Uuid, + source_type -> Text, + user_message_id -> Nullable, + assistant_message_id -> Nullable, + conversation_id -> Nullable, + vector_enc -> Bytea, + embedding_model -> Text, + vector_dim -> Int4, + content_enc -> Bytea, + metadata_enc -> Nullable, + tags_enc -> Array>, + token_count -> Int4, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + diesel::table! { user_instructions (id) { id -> Int8, @@ -343,6 +525,8 @@ diesel::table! { prompt_tokens -> Int4, created_at -> Timestamptz, updated_at -> Timestamptz, + attachment_text_enc -> Nullable, + assistant_reaction -> Nullable, } } @@ -361,6 +545,17 @@ diesel::table! { } } +diesel::table! { + user_preferences (id) { + id -> Int8, + user_id -> Uuid, + key -> Text, + value_enc -> Bytea, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + diesel::table! { users (id) { id -> Int4, @@ -375,9 +570,17 @@ diesel::table! { } } +diesel::joinable!(agent_schedule_runs -> agent_schedules (schedule_id)); +diesel::joinable!(agent_schedule_runs -> agents (agent_id)); +diesel::joinable!(agent_schedules -> agents (agent_id)); +diesel::joinable!(agents -> conversations (conversation_id)); diesel::joinable!(assistant_messages -> conversations (conversation_id)); diesel::joinable!(assistant_messages -> responses (response_id)); +diesel::joinable!(conversation_summaries -> conversations (conversation_id)); diesel::joinable!(invite_codes -> orgs (org_id)); +diesel::joinable!(notification_deliveries -> notification_events (event_id)); +diesel::joinable!(notification_deliveries -> push_devices (push_device_id)); +diesel::joinable!(notification_events -> org_projects (project_id)); diesel::joinable!(org_memberships -> orgs (org_id)); diesel::joinable!(org_project_secrets -> org_projects (project_id)); diesel::joinable!(org_projects -> orgs (org_id)); @@ -391,6 +594,9 @@ diesel::joinable!(tool_calls -> responses (response_id)); diesel::joinable!(tool_outputs -> conversations (conversation_id)); diesel::joinable!(tool_outputs -> responses (response_id)); diesel::joinable!(tool_outputs -> tool_calls (tool_call_fk)); +diesel::joinable!(user_embeddings -> assistant_messages (assistant_message_id)); +diesel::joinable!(user_embeddings -> conversations (conversation_id)); +diesel::joinable!(user_embeddings -> user_messages (user_message_id)); diesel::joinable!(user_messages -> conversations (conversation_id)); diesel::joinable!(user_messages -> responses (response_id)); diesel::joinable!(user_oauth_connections -> oauth_providers (provider_id)); @@ -398,11 +604,18 @@ diesel::joinable!(users -> org_projects (project_id)); diesel::allow_tables_to_appear_in_same_query!( account_deletion_requests, + agent_schedule_runs, + agent_schedules, + agents, assistant_messages, + conversation_summaries, conversations, email_verifications, enclave_secrets, invite_codes, + memory_blocks, + notification_deliveries, + notification_events, oauth_providers, org_memberships, org_project_secrets, @@ -414,15 +627,18 @@ diesel::allow_tables_to_appear_in_same_query!( platform_password_reset_requests, platform_users, project_settings, + push_devices, reasoning_items, responses, token_usage, tool_calls, tool_outputs, user_api_keys, + user_embeddings, user_instructions, user_kv, user_messages, user_oauth_connections, + user_preferences, users, ); diff --git a/src/models/user_embeddings.rs b/src/models/user_embeddings.rs new file mode 100644 index 00000000..804cb470 --- /dev/null +++ b/src/models/user_embeddings.rs @@ -0,0 +1,85 @@ +use crate::models::schema::user_embeddings; +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use uuid::Uuid; + +#[derive(Error, Debug)] +pub enum UserEmbeddingError { + #[error("Database error: {0}")] + DatabaseError(#[from] diesel::result::Error), +} + +#[derive(Queryable, Identifiable, AsChangeset, Serialize, Deserialize, Clone, Debug)] +#[diesel(table_name = user_embeddings)] +pub struct UserEmbedding { + pub id: i64, + pub uuid: Uuid, + pub user_id: Uuid, + pub source_type: String, + pub user_message_id: Option, + pub assistant_message_id: Option, + pub conversation_id: Option, + pub vector_enc: Vec, + pub embedding_model: String, + pub vector_dim: i32, + pub content_enc: Vec, + pub metadata_enc: Option>, + pub tags_enc: Vec>, + pub token_count: i32, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Insertable, Debug, Clone)] +#[diesel(table_name = user_embeddings)] +pub struct NewUserEmbedding { + pub uuid: Uuid, + pub user_id: Uuid, + pub source_type: String, + pub user_message_id: Option, + pub assistant_message_id: Option, + pub conversation_id: Option, + pub vector_enc: Vec, + pub embedding_model: String, + pub vector_dim: i32, + pub content_enc: Vec, + pub metadata_enc: Option>, + pub tags_enc: Vec>, + pub token_count: i32, +} + +impl NewUserEmbedding { + pub fn insert(&self, conn: &mut PgConnection) -> Result { + diesel::insert_into(user_embeddings::table) + .values(self) + .get_result::(conn) + .map_err(UserEmbeddingError::DatabaseError) + } +} + +impl UserEmbedding { + pub fn delete_all_for_user( + conn: &mut PgConnection, + user_uuid: Uuid, + ) -> Result { + diesel::delete(user_embeddings::table.filter(user_embeddings::user_id.eq(user_uuid))) + .execute(conn) + .map_err(UserEmbeddingError::DatabaseError) + } + + pub fn delete_by_uuid_for_user( + conn: &mut PgConnection, + user_uuid: Uuid, + embedding_uuid: Uuid, + ) -> Result { + diesel::delete( + user_embeddings::table + .filter(user_embeddings::user_id.eq(user_uuid)) + .filter(user_embeddings::uuid.eq(embedding_uuid)), + ) + .execute(conn) + .map_err(UserEmbeddingError::DatabaseError) + } +} diff --git a/src/models/user_preferences.rs b/src/models/user_preferences.rs new file mode 100644 index 00000000..152daf76 --- /dev/null +++ b/src/models/user_preferences.rs @@ -0,0 +1,115 @@ +use crate::models::schema::user_preferences; +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use thiserror::Error; +use uuid::Uuid; + +pub const USER_PREFERENCE_TIMEZONE: &str = "timezone"; +pub const USER_PREFERENCE_LOCALE: &str = "locale"; + +#[derive(Error, Debug)] +pub enum UserPreferenceError { + #[error("Database error: {0}")] + DatabaseError(#[from] diesel::result::Error), + #[error("Invalid preference: {0}")] + InvalidPreference(String), +} + +#[derive(Queryable, Identifiable, AsChangeset, Clone, Debug)] +#[diesel(table_name = user_preferences)] +pub struct UserPreference { + pub id: i64, + pub user_id: Uuid, + pub key: String, + pub value_enc: Vec, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl UserPreference { + pub fn get_by_user_and_key( + conn: &mut PgConnection, + lookup_user_id: Uuid, + lookup_key: &str, + ) -> Result, UserPreferenceError> { + user_preferences::table + .filter(user_preferences::user_id.eq(lookup_user_id)) + .filter(user_preferences::key.eq(lookup_key)) + .first::(conn) + .optional() + .map_err(UserPreferenceError::DatabaseError) + } + + pub fn validate(key: &str, value: &str) -> Result<(), UserPreferenceError> { + match key { + USER_PREFERENCE_TIMEZONE => value.parse::().map(|_| ()).map_err(|_| { + UserPreferenceError::InvalidPreference(format!( + "Invalid timezone '{value}'. Use an IANA timezone like 'America/Chicago'" + )) + }), + USER_PREFERENCE_LOCALE => validate_locale(value), + _ => Ok(()), + } + } +} + +fn validate_locale(value: &str) -> Result<(), UserPreferenceError> { + let trimmed = value.trim(); + if trimmed.len() < 2 || trimmed.len() > 35 { + return Err(UserPreferenceError::InvalidPreference(format!( + "Invalid locale '{value}'" + ))); + } + + let starts_or_ends_with_separator = + trimmed.chars().next().is_some_and(|c| c == '-' || c == '_') + || trimmed.chars().last().is_some_and(|c| c == '-' || c == '_'); + + if starts_or_ends_with_separator || trimmed.contains("--") || trimmed.contains("__") { + return Err(UserPreferenceError::InvalidPreference(format!( + "Invalid locale '{value}'" + ))); + } + + if !trimmed + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Err(UserPreferenceError::InvalidPreference(format!( + "Invalid locale '{value}'" + ))); + } + + Ok(()) +} + +#[derive(Insertable, Debug, Clone)] +#[diesel(table_name = user_preferences)] +pub struct NewUserPreference { + pub user_id: Uuid, + pub key: String, + pub value_enc: Vec, +} + +impl NewUserPreference { + pub fn new(user_id: Uuid, key: impl Into, value_enc: Vec) -> Self { + Self { + user_id, + key: key.into(), + value_enc, + } + } + + pub fn insert_or_update( + &self, + conn: &mut PgConnection, + ) -> Result { + diesel::insert_into(user_preferences::table) + .values(self) + .on_conflict((user_preferences::user_id, user_preferences::key)) + .do_update() + .set(user_preferences::value_enc.eq(self.value_enc.clone())) + .get_result::(conn) + .map_err(UserPreferenceError::DatabaseError) + } +} diff --git a/src/private_key.rs b/src/private_key.rs index 9d45aae8..1c20460b 100644 --- a/src/private_key.rs +++ b/src/private_key.rs @@ -37,29 +37,17 @@ pub fn decrypt_user_seed_to_key( seed_phrase_derivation_path: Option<&str>, ) -> Result { // If seed_phrase_derivation_path is provided, derive a child mnemonic using BIP-85 - let (source_mnemonic, uses_bip85) = if let Some(bip85_path) = seed_phrase_derivation_path { + let source_mnemonic = if let Some(bip85_path) = seed_phrase_derivation_path { info!("Using BIP-85 derivation with path: {}", bip85_path); - ( - decrypt_and_derive_bip85_mnemonic(enclave_key, encrypted_seed, bip85_path)?, - true, - ) + decrypt_and_derive_bip85_mnemonic(enclave_key, encrypted_seed, bip85_path)? } else { - debug!("Using root mnemonic (no BIP-85 derivation)"); - ( - decrypt_user_seed_to_mnemonic(enclave_key, encrypted_seed)?, - false, - ) + decrypt_user_seed_to_mnemonic(enclave_key, encrypted_seed)? }; // Generate seed from the appropriate mnemonic (either the root or the BIP-85 derived one) - info!( - "Generating seed from {} mnemonic", - if uses_bip85 { "BIP-85 derived" } else { "root" } - ); let seed = source_mnemonic.to_seed(""); // Create extended private key from the seed - debug!("Creating extended private key from seed"); let xprivkey = Xpriv::new_master(Network::Bitcoin, &seed) .map_err(|e| Error::EncryptionError(e.to_string()))?; @@ -74,7 +62,6 @@ pub fn decrypt_user_seed_to_key( debug!("Successfully derived child key using BIP-32 path"); Ok(derived_key.private_key) } else { - debug!("Using master key (no BIP-32 derivation)"); Ok(xprivkey.private_key) } } diff --git a/src/push/apns.rs b/src/push/apns.rs new file mode 100644 index 00000000..dea8af88 --- /dev/null +++ b/src/push/apns.rs @@ -0,0 +1,265 @@ +use crate::models::notification_events::NotificationEvent; +use crate::models::project_settings::IosPushSettings; +use crate::models::push_devices::PushDevice; +use crate::push::crypto::encrypt_preview_payload; +use crate::push::{ + CachedApnsToken, NotificationPreviewPayload, PushError, PushSendOutcome, PushTransport, + APNS_JWT_CACHE_LIFETIME_MINUTES, +}; +use crate::web::platform::PROJECT_APNS_AUTH_KEY_P8; +use crate::AppState; +use chrono::{Duration, Utc}; +use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; +use serde::Serialize; +use serde_json::{json, Value}; +use std::sync::Arc; + +#[derive(Debug, Serialize)] +struct ApnsJwtClaims { + iss: String, + iat: i64, +} + +pub struct ApnsSendRequest<'a> { + pub event: &'a NotificationEvent, + pub device: &'a PushDevice, + pub push_token: &'a str, + pub ios_settings: &'a IosPushSettings, + pub preview_payload: Option<&'a NotificationPreviewPayload>, + pub send_encrypted_preview: bool, +} + +pub async fn send_apns_notification( + state: &Arc, + transport: &PushTransport, + request: ApnsSendRequest<'_>, +) -> Result { + let auth_token = get_apns_auth_token( + state, + transport, + request.event.project_id, + request.ios_settings, + ) + .await?; + let body = build_apns_payload( + request.event, + request.device, + request.preview_payload, + request.send_encrypted_preview, + )?; + let endpoint = match request.ios_settings.apns_environment.as_str() { + "dev" => format!( + "https://api.sandbox.push.apple.com/3/device/{}", + request.push_token + ), + _ => format!("https://api.push.apple.com/3/device/{}", request.push_token), + }; + + let mut http_request = transport + .apns_client + .post(endpoint) + .bearer_auth(auth_token) + .header("apns-topic", request.ios_settings.bundle_id.as_str()) + .header("apns-push-type", "alert") + .header( + "apns-priority", + if request.event.priority == "high" { + "10" + } else { + "5" + }, + ); + + if let Some(collapse_key) = &request.event.collapse_key { + http_request = http_request.header("apns-collapse-id", collapse_key); + } + + if let Some(expires_at) = request.event.expires_at { + http_request = http_request.header("apns-expiration", expires_at.timestamp().to_string()); + } + + let response = http_request.json(&body).send().await?; + let status_code = response.status().as_u16() as i32; + let apns_id = response + .headers() + .get("apns-id") + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let status = response.status(); + let response_body = response.text().await.unwrap_or_default(); + let reason = parse_apns_reason(&response_body); + + if status.is_success() { + return Ok(PushSendOutcome::Sent { + provider_message_id: apns_id, + provider_status_code: Some(status_code), + }); + } + + if matches!( + reason.as_deref(), + Some("ExpiredProviderToken" | "InvalidProviderToken") + ) { + invalidate_apns_cache(transport, request.event.project_id, request.ios_settings).await; + } + + let error_message = reason.unwrap_or_else(|| response_body.clone()); + let outcome = match status.as_u16() { + 410 => PushSendOutcome::InvalidToken { + provider_status_code: Some(status_code), + error: if error_message.is_empty() { + "Unregistered".to_string() + } else { + error_message + }, + }, + 403 if matches!( + error_message.as_str(), + "ExpiredProviderToken" | "InvalidProviderToken" + ) => + { + PushSendOutcome::Retryable { + provider_status_code: Some(status_code), + error: error_message, + } + } + 429 | 500 | 503 => PushSendOutcome::Retryable { + provider_status_code: Some(status_code), + error: error_message, + }, + _ => PushSendOutcome::Failed { + provider_status_code: Some(status_code), + error: error_message, + }, + }; + + Ok(outcome) +} + +async fn get_apns_auth_token( + state: &Arc, + transport: &PushTransport, + project_id: i32, + ios_settings: &IosPushSettings, +) -> Result { + let cache_key = format!( + "{}:{}:{}", + project_id, ios_settings.team_id, ios_settings.key_id + ); + { + let cache = transport.apns_tokens.read().await; + if let Some(cached) = cache.get(&cache_key) { + if cached.expires_at > Utc::now() + Duration::minutes(5) { + return Ok(cached.token.clone()); + } + } + } + + let private_key_pem = state + .get_project_secret_string(project_id, PROJECT_APNS_AUTH_KEY_P8) + .await? + .ok_or_else(|| PushError::InvalidSecret("missing APNs auth key".to_string()))?; + + let mut header = Header::new(Algorithm::ES256); + header.kid = Some(ios_settings.key_id.clone()); + + let claims = ApnsJwtClaims { + iss: ios_settings.team_id.clone(), + iat: Utc::now().timestamp() - 30, + }; + let signing_key = EncodingKey::from_ec_pem(private_key_pem.as_bytes()) + .map_err(|e| PushError::InvalidSecret(e.to_string()))?; + let token = encode(&header, &claims, &signing_key)?; + let expires_at = Utc::now() + Duration::minutes(APNS_JWT_CACHE_LIFETIME_MINUTES); + + let mut cache = transport.apns_tokens.write().await; + cache.insert( + cache_key, + CachedApnsToken { + token: token.clone(), + expires_at, + }, + ); + + Ok(token) +} + +async fn invalidate_apns_cache( + transport: &PushTransport, + project_id: i32, + ios_settings: &IosPushSettings, +) { + let cache_key = format!( + "{}:{}:{}", + project_id, ios_settings.team_id, ios_settings.key_id + ); + let mut cache = transport.apns_tokens.write().await; + cache.remove(&cache_key); +} + +fn build_apns_payload( + event: &NotificationEvent, + device: &PushDevice, + preview_payload: Option<&NotificationPreviewPayload>, + send_encrypted_preview: bool, +) -> Result { + // Routing metadata is intentionally provider-visible in v1; the privacy boundary here is + // plaintext notification content, which stays generic unless the device decrypts os_push. + let metadata = preview_payload.map(|payload| { + json!({ + "notification_id": payload.notification_id.to_string(), + "kind": payload.kind, + "message_id": payload.message_id.to_string(), + "thread_id": payload.thread_id, + "deep_link": payload.deep_link, + }) + }); + + let mut root = json!({ + "aps": { + "alert": { + "title": event.fallback_title, + "body": event.fallback_body, + }, + "sound": "default", + "mutable-content": 1, + } + }); + + if let Some(payload) = preview_payload { + if let Some(aps) = root.get_mut("aps").and_then(|value| value.as_object_mut()) { + aps.insert( + "thread-id".to_string(), + Value::String(payload.thread_id.clone()), + ); + } + } + + if let Some(metadata) = metadata { + root.as_object_mut() + .expect("root payload must be an object") + .insert("os_meta".to_string(), metadata); + } + + if send_encrypted_preview { + if let Some(payload) = preview_payload { + let envelope = encrypt_preview_payload(device, payload)?; + root.as_object_mut() + .expect("root payload must be an object") + .insert("os_push".to_string(), serde_json::to_value(envelope)?); + } + } + + Ok(root) +} + +fn parse_apns_reason(response_body: &str) -> Option { + serde_json::from_str::(response_body) + .ok() + .and_then(|value| { + value + .get("reason") + .and_then(|reason| reason.as_str()) + .map(str::to_owned) + }) +} diff --git a/src/push/crypto.rs b/src/push/crypto.rs new file mode 100644 index 00000000..59af8b8d --- /dev/null +++ b/src/push/crypto.rs @@ -0,0 +1,151 @@ +use crate::encrypt::generate_random; +use crate::models::push_devices::PushDevice; +use crate::push::{NotificationPreviewPayload, PushError}; +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Nonce}; +use base64::{engine::general_purpose, Engine}; +use hkdf::Hkdf; +use p256::ecdh::EphemeralSecret; +use p256::elliptic_curve::sec1::ToEncodedPoint; +use p256::pkcs8::DecodePublicKey; +use p256::PublicKey; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; + +pub const PUSH_PREVIEW_INFO: &[u8] = b"opensecret-push-preview-v1"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EncryptedPreviewEnvelope { + #[serde(alias = "v")] + pub enc_v: i32, + pub alg: String, + pub kid: String, + pub epk: String, + pub salt: String, + pub nonce: String, + pub ciphertext: String, +} + +pub fn encrypt_preview_payload( + device: &PushDevice, + payload: &NotificationPreviewPayload, +) -> Result { + let recipient_key = PublicKey::from_public_key_der(&device.notification_public_key) + .map_err(|e| PushError::CryptoError(e.to_string()))?; + let ephemeral_secret = EphemeralSecret::random(&mut p256::elliptic_curve::rand_core::OsRng); + let ephemeral_public = PublicKey::from(&ephemeral_secret); + let shared_secret = ephemeral_secret.diffie_hellman(&recipient_key); + + let salt = generate_random::<32>(); + let hkdf = Hkdf::::new(Some(&salt), shared_secret.raw_secret_bytes().as_slice()); + let mut key = [0_u8; 32]; + hkdf.expand(PUSH_PREVIEW_INFO, &mut key) + .map_err(|_| PushError::CryptoError("failed to derive push preview key".to_string()))?; + + let nonce_bytes = generate_random::<12>(); + let cipher = + Aes256Gcm::new_from_slice(&key).map_err(|e| PushError::CryptoError(e.to_string()))?; + let plaintext = serde_json::to_vec(payload)?; + let ciphertext = cipher + .encrypt(Nonce::from_slice(&nonce_bytes), plaintext.as_ref()) + .map_err(|e| PushError::CryptoError(e.to_string()))?; + + Ok(EncryptedPreviewEnvelope { + enc_v: 1, + alg: "p256-hkdf-sha256-aes256gcm".to_string(), + kid: device.uuid.to_string(), + epk: general_purpose::STANDARD.encode(ephemeral_public.to_encoded_point(false).as_bytes()), + salt: general_purpose::STANDARD.encode(salt), + nonce: general_purpose::STANDARD.encode(nonce_bytes), + ciphertext: general_purpose::STANDARD.encode(ciphertext), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::push_devices::{ + PushDevice, PUSH_ENV_PROD, PUSH_KEY_ALGORITHM_P256_ECDH_V1, PUSH_PLATFORM_IOS, + PUSH_PROVIDER_APNS, + }; + use aes_gcm::aead::{Aead, KeyInit}; + use aes_gcm::{Aes256Gcm, Nonce}; + use base64::{engine::general_purpose, Engine}; + use chrono::Utc; + use hkdf::Hkdf; + use p256::elliptic_curve::rand_core::OsRng; + use p256::pkcs8::EncodePublicKey; + use p256::SecretKey; + use uuid::Uuid; + + #[test] + fn encrypt_preview_payload_round_trips() { + let recipient_secret = SecretKey::random(&mut OsRng); + let recipient_public = recipient_secret.public_key(); + let device = PushDevice { + id: 1, + uuid: Uuid::new_v4(), + user_id: Uuid::new_v4(), + installation_id: Uuid::new_v4(), + platform: PUSH_PLATFORM_IOS.to_string(), + provider: PUSH_PROVIDER_APNS.to_string(), + environment: PUSH_ENV_PROD.to_string(), + app_id: "ai.trymaple.ios".to_string(), + push_token_enc: vec![1, 2, 3], + push_token_hash: vec![4, 5, 6], + notification_public_key: recipient_public + .to_public_key_der() + .unwrap() + .as_bytes() + .to_vec(), + key_algorithm: PUSH_KEY_ALGORITHM_P256_ECDH_V1.to_string(), + supports_encrypted_preview: true, + supports_background_processing: true, + last_seen_at: Utc::now(), + revoked_at: None, + created_at: Utc::now(), + updated_at: Utc::now(), + }; + + let payload = NotificationPreviewPayload { + v: 1, + notification_id: Uuid::new_v4(), + message_id: Uuid::new_v4(), + kind: "agent.message".to_string(), + title: "Maple".to_string(), + body: "Hello from Maple".to_string(), + deep_link: "opensecret://agent".to_string(), + thread_id: "agent:main".to_string(), + sent_at: Utc::now().timestamp(), + }; + + let envelope = encrypt_preview_payload(&device, &payload).unwrap(); + let epk_bytes = general_purpose::STANDARD.decode(&envelope.epk).unwrap(); + let salt = general_purpose::STANDARD.decode(&envelope.salt).unwrap(); + let nonce = general_purpose::STANDARD.decode(&envelope.nonce).unwrap(); + let ciphertext = general_purpose::STANDARD + .decode(&envelope.ciphertext) + .unwrap(); + + let ephemeral_public = PublicKey::from_sec1_bytes(&epk_bytes).unwrap(); + let shared_secret = p256::ecdh::diffie_hellman( + recipient_secret.to_nonzero_scalar(), + ephemeral_public.as_affine(), + ); + let hkdf = Hkdf::::new(Some(&salt), shared_secret.raw_secret_bytes().as_slice()); + let mut key = [0_u8; 32]; + hkdf.expand(PUSH_PREVIEW_INFO, &mut key).unwrap(); + + let cipher = Aes256Gcm::new_from_slice(&key).unwrap(); + let plaintext = cipher + .decrypt(Nonce::from_slice(&nonce), ciphertext.as_ref()) + .unwrap(); + let decoded: NotificationPreviewPayload = serde_json::from_slice(&plaintext).unwrap(); + + assert_eq!(decoded.notification_id, payload.notification_id); + assert_eq!(decoded.body, payload.body); + assert_eq!(decoded.deep_link, payload.deep_link); + assert_eq!(envelope.enc_v, 1); + assert_eq!(envelope.alg, "p256-hkdf-sha256-aes256gcm"); + } +} diff --git a/src/push/fcm.rs b/src/push/fcm.rs new file mode 100644 index 00000000..1caf7be8 --- /dev/null +++ b/src/push/fcm.rs @@ -0,0 +1,305 @@ +use crate::models::notification_events::NotificationEvent; +use crate::models::project_settings::AndroidPushSettings; +use crate::models::push_devices::PushDevice; +use crate::push::{ + CachedFcmToken, NotificationPreviewPayload, PushError, PushSendOutcome, PushTransport, + FCM_TOKEN_CACHE_SAFETY_MARGIN_SECONDS, +}; +use crate::web::platform::PROJECT_FCM_SERVICE_ACCOUNT_JSON; +use crate::AppState; +use chrono::{Duration, Utc}; +use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::sync::Arc; + +const GOOGLE_TOKEN_SCOPE: &str = "https://www.googleapis.com/auth/firebase.messaging"; +const DEFAULT_GOOGLE_TOKEN_URI: &str = "https://oauth2.googleapis.com/token"; + +#[derive(Debug, Deserialize)] +struct FcmServiceAccount { + pub client_email: String, + pub private_key: String, + pub project_id: Option, + pub token_uri: Option, +} + +#[derive(Debug, Serialize)] +struct FcmJwtClaims<'a> { + iss: &'a str, + scope: &'a str, + aud: &'a str, + exp: i64, + iat: i64, +} + +#[derive(Debug, Deserialize)] +struct FcmAccessTokenResponse { + access_token: String, + expires_in: i64, +} + +#[derive(Debug, Deserialize)] +struct FcmSendResponse { + name: String, +} + +#[derive(Debug, Deserialize)] +struct GoogleApiErrorEnvelope { + error: GoogleApiError, +} + +#[derive(Debug, Deserialize)] +struct GoogleApiError { + status: Option, + message: Option, + details: Option>, +} + +pub async fn send_fcm_notification( + state: &Arc, + transport: &PushTransport, + event: &NotificationEvent, + _device: &PushDevice, + push_token: &str, + android_settings: &AndroidPushSettings, + preview_payload: Option<&NotificationPreviewPayload>, +) -> Result { + let service_account = load_fcm_service_account(state, event.project_id).await?; + let project_id = if android_settings.firebase_project_id.trim().is_empty() { + service_account + .project_id + .clone() + .ok_or_else(|| PushError::InvalidSecret("missing FCM project_id".to_string()))? + } else { + android_settings.firebase_project_id.clone() + }; + let access_token = get_fcm_access_token(transport, event.project_id, &service_account).await?; + let body = build_fcm_payload(event, push_token, preview_payload); + let response = transport + .client + .post(format!( + "https://fcm.googleapis.com/v1/projects/{}/messages:send", + project_id + )) + .bearer_auth(access_token) + .json(&body) + .send() + .await?; + + let status_code = response.status().as_u16() as i32; + let status = response.status(); + let response_body = response.text().await.unwrap_or_default(); + + if status.is_success() { + let parsed: FcmSendResponse = serde_json::from_str(&response_body)?; + return Ok(PushSendOutcome::Sent { + provider_message_id: Some(parsed.name), + provider_status_code: Some(status_code), + }); + } + + let parsed_error = serde_json::from_str::(&response_body).ok(); + let error_status = parsed_error + .as_ref() + .and_then(|body| body.error.status.clone()) + .unwrap_or_default(); + let error_message = parsed_error + .as_ref() + .and_then(|body| body.error.message.clone()) + .unwrap_or_else(|| response_body.clone()); + let fcm_error_code = parsed_error + .as_ref() + .and_then(|body| extract_fcm_error_code(body.error.details.as_ref())); + + if matches!(status.as_u16(), 401 | 403) { + invalidate_fcm_cache(transport, event.project_id, &service_account.client_email).await; + } + + let outcome = if matches!(fcm_error_code.as_deref(), Some("UNREGISTERED")) + || is_invalid_registration(&error_status, &error_message) + { + PushSendOutcome::InvalidToken { + provider_status_code: Some(status_code), + error: if let Some(code) = fcm_error_code { + format!("{}: {}", code, error_message) + } else { + error_message + }, + } + } else if matches!(status.as_u16(), 401 | 403 | 429 | 500 | 503) { + PushSendOutcome::Retryable { + provider_status_code: Some(status_code), + error: error_message, + } + } else { + PushSendOutcome::Failed { + provider_status_code: Some(status_code), + error: error_message, + } + }; + + Ok(outcome) +} + +async fn load_fcm_service_account( + state: &Arc, + project_id: i32, +) -> Result { + let raw = state + .get_project_secret_string(project_id, PROJECT_FCM_SERVICE_ACCOUNT_JSON) + .await? + .ok_or_else(|| PushError::InvalidSecret("missing FCM service account JSON".to_string()))?; + + serde_json::from_str(&raw).map_err(PushError::from) +} + +async fn get_fcm_access_token( + transport: &PushTransport, + project_id: i32, + service_account: &FcmServiceAccount, +) -> Result { + let cache_key = format!("{}:{}", project_id, service_account.client_email); + { + let cache = transport.fcm_tokens.read().await; + if let Some(cached) = cache.get(&cache_key) { + if cached.expires_at + > Utc::now() + Duration::seconds(FCM_TOKEN_CACHE_SAFETY_MARGIN_SECONDS) + { + return Ok(cached.token.clone()); + } + } + } + + let token_uri = service_account + .token_uri + .as_deref() + .unwrap_or(DEFAULT_GOOGLE_TOKEN_URI); + let now = Utc::now().timestamp(); + let claims = FcmJwtClaims { + iss: &service_account.client_email, + scope: GOOGLE_TOKEN_SCOPE, + aud: token_uri, + exp: now + 3600, + iat: now - 30, + }; + let header = Header::new(Algorithm::RS256); + let signing_key = EncodingKey::from_rsa_pem(service_account.private_key.as_bytes()) + .map_err(|e| PushError::InvalidSecret(e.to_string()))?; + let assertion = encode(&header, &claims, &signing_key)?; + + let response = transport + .client + .post(token_uri) + .form(&[ + ("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), + ("assertion", assertion.as_str()), + ]) + .send() + .await?; + let response_status = response.status(); + let response_body = response.text().await.unwrap_or_default(); + + if !response_status.is_success() { + let error = format!("FCM OAuth failed ({}): {}", response_status, response_body); + return Err( + if response_status.as_u16() == 429 || response_status.is_server_error() { + PushError::ProviderRetryable(error) + } else { + PushError::ProviderError(error) + }, + ); + } + + let token_response: FcmAccessTokenResponse = serde_json::from_str(&response_body)?; + let expires_at = Utc::now() + Duration::seconds(token_response.expires_in); + let token = token_response.access_token; + + let mut cache = transport.fcm_tokens.write().await; + cache.insert( + cache_key, + CachedFcmToken { + token: token.clone(), + expires_at, + }, + ); + + Ok(token) +} + +async fn invalidate_fcm_cache(transport: &PushTransport, project_id: i32, client_email: &str) { + let cache_key = format!("{}:{}", project_id, client_email); + let mut cache = transport.fcm_tokens.write().await; + cache.remove(&cache_key); +} + +fn build_fcm_payload( + event: &NotificationEvent, + push_token: &str, + preview_payload: Option<&NotificationPreviewPayload>, +) -> Value { + let ttl_seconds = event + .expires_at + .map(|expires_at| (expires_at - Utc::now()).num_seconds().max(0)) + .unwrap_or(60 * 60 * 24 * 7); + let collapse_key = event + .collapse_key + .clone() + .unwrap_or_else(|| format!("notif:{}", event.uuid)); + + // Android v1 keeps routing metadata in plaintext FCM data fields; the privacy requirement is + // that message content stays generic/provider-visible while authoritative content is fetched or decrypted on-device. + let data = if let Some(payload) = preview_payload { + json!({ + "notification_id": payload.notification_id.to_string(), + "kind": payload.kind, + "message_id": payload.message_id.to_string(), + "thread_id": payload.thread_id, + "deep_link": payload.deep_link, + }) + } else { + json!({ + "notification_id": event.uuid.to_string(), + "kind": event.kind, + }) + }; + + json!({ + "message": { + "token": push_token, + "notification": { + "title": event.fallback_title, + "body": event.fallback_body, + }, + "data": data, + "android": { + "priority": if event.priority == "high" { "HIGH" } else { "NORMAL" }, + "ttl": format!("{}s", ttl_seconds), + "collapse_key": collapse_key, + "notification": { + "channel_id": "maple_messages", + "tag": event.uuid.to_string(), + "click_action": "OPEN_THREAD", + } + } + } + }) +} + +fn extract_fcm_error_code(details: Option<&Vec>) -> Option { + details.and_then(|entries| { + entries.iter().find_map(|entry| { + entry + .get("errorCode") + .and_then(|value| value.as_str()) + .map(str::to_owned) + }) + }) +} + +fn is_invalid_registration(error_status: &str, error_message: &str) -> bool { + error_status == "INVALID_ARGUMENT" + && error_message + .to_ascii_lowercase() + .contains("registration token") +} diff --git a/src/push/mod.rs b/src/push/mod.rs new file mode 100644 index 00000000..4a38ed57 --- /dev/null +++ b/src/push/mod.rs @@ -0,0 +1,397 @@ +pub mod apns; +pub mod crypto; +pub mod fcm; +pub mod worker; + +use crate::db::DBError; +use crate::encrypt::encrypt_with_key; +use crate::models::notification_deliveries::{NewNotificationDelivery, NotificationDeliveryError}; +use crate::models::notification_events::{ + NewNotificationEvent, NotificationEvent, NotificationEventError, + NOTIFICATION_DELIVERY_MODE_ENCRYPTED_PREVIEW, NOTIFICATION_DELIVERY_MODE_GENERIC, + NOTIFICATION_KIND_AGENT_MESSAGE, NOTIFICATION_PRIORITY_HIGH, NOTIFICATION_PRIORITY_NORMAL, +}; +use crate::models::project_settings::ProjectSettingError; +use crate::models::push_devices::{PushDevice, PushDeviceError}; +use crate::models::users::User; +use crate::{AppState, Error}; +use chrono::{DateTime, Duration, Utc}; +use diesel::Connection; +use secp256k1::SecretKey; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use thiserror::Error; +use tokio::sync::RwLock; +use tracing::warn; +use uuid::Uuid; + +pub const AGENT_NOTIFICATION_FALLBACK_TITLE: &str = "New Maple message"; +pub const AGENT_NOTIFICATION_FALLBACK_BODY: &str = "Open Maple to view your encrypted message"; +pub const APNS_JWT_CACHE_LIFETIME_MINUTES: i64 = 50; +pub const FCM_TOKEN_CACHE_SAFETY_MARGIN_SECONDS: i64 = 60; +pub const PUSH_PREVIEW_BODY_MAX_BYTES: usize = 180; + +#[derive(Debug, Error)] +pub enum PushError { + #[error("Database connection error")] + ConnectionError, + #[error("Database error: {0}")] + DatabaseError(#[from] diesel::result::Error), + #[error("Push device error: {0}")] + PushDeviceError(#[from] PushDeviceError), + #[error("Notification event error: {0}")] + NotificationEventError(#[from] NotificationEventError), + #[error("Notification delivery error: {0}")] + NotificationDeliveryError(#[from] NotificationDeliveryError), + #[error("Project settings error: {0}")] + ProjectSettingError(#[from] ProjectSettingError), + #[error("DB error: {0}")] + DbError(#[from] DBError), + #[error("Application error: {0}")] + AppError(#[from] Error), + #[error("Serialization error: {0}")] + SerializationError(#[from] serde_json::Error), + #[error("Encryption error: {0}")] + EncryptionError(#[from] crate::encrypt::EncryptError), + #[error("HTTP error: {0}")] + HttpError(#[from] reqwest::Error), + #[error("JWT error: {0}")] + JwtError(#[from] jsonwebtoken::errors::Error), + #[error("Crypto error: {0}")] + CryptoError(String), + #[error("Invalid secret: {0}")] + InvalidSecret(String), + #[error("Provider error: {0}")] + ProviderError(String), + #[error("Retryable provider error: {0}")] + ProviderRetryable(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PushDeliveryMode { + Generic, + EncryptedPreview, +} + +impl PushDeliveryMode { + pub fn as_str(&self) -> &'static str { + match self { + Self::Generic => NOTIFICATION_DELIVERY_MODE_GENERIC, + Self::EncryptedPreview => NOTIFICATION_DELIVERY_MODE_ENCRYPTED_PREVIEW, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PushPriority { + #[allow(dead_code)] + Normal, + High, +} + +impl PushPriority { + pub fn as_str(&self) -> &'static str { + match self { + Self::Normal => NOTIFICATION_PRIORITY_NORMAL, + Self::High => NOTIFICATION_PRIORITY_HIGH, + } + } +} + +#[derive(Debug, Clone)] +pub enum AgentPushTarget { + Main, + Subagent(Uuid), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationPreviewPayload { + pub v: i32, + pub notification_id: Uuid, + pub message_id: Uuid, + pub kind: String, + pub title: String, + pub body: String, + pub deep_link: String, + pub thread_id: String, + pub sent_at: i64, +} + +#[derive(Debug, Clone)] +pub struct NotificationPreviewPayloadInput { + pub message_id: Uuid, + pub kind: String, + pub title: String, + pub body: String, + pub deep_link: String, + pub thread_id: String, + pub sent_at: i64, +} + +fn normalize_preview_body(message_text: &str) -> String { + let normalized = message_text + .split_whitespace() + .collect::>() + .join(" "); + truncate_utf8_with_ellipsis(&normalized, PUSH_PREVIEW_BODY_MAX_BYTES) +} + +fn truncate_utf8_with_ellipsis(input: &str, max_bytes: usize) -> String { + if input.len() <= max_bytes { + return input.to_string(); + } + + let ellipsis = "…"; + if max_bytes <= ellipsis.len() { + return String::new(); + } + + let mut end = max_bytes - ellipsis.len(); + while end > 0 && !input.is_char_boundary(end) { + end -= 1; + } + + let truncated = input[..end].trim_end(); + if truncated.is_empty() { + String::new() + } else { + format!("{}{}", truncated, ellipsis) + } +} + +#[derive(Debug, Clone)] +pub struct EnqueueNotificationRequest { + pub project_id: i32, + pub user_id: Uuid, + pub kind: String, + pub delivery_mode: PushDeliveryMode, + pub priority: PushPriority, + pub fallback_title: String, + pub fallback_body: String, + pub preview_payload: Option, + pub not_before_at: Option>, + pub expires_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueuedNotification { + pub notification_id: Uuid, + pub delivery_count: usize, +} + +#[derive(Debug, Clone)] +pub enum PushSendOutcome { + Sent { + provider_message_id: Option, + provider_status_code: Option, + }, + Retryable { + provider_status_code: Option, + error: String, + }, + InvalidToken { + provider_status_code: Option, + error: String, + }, + Failed { + provider_status_code: Option, + error: String, + }, +} + +#[derive(Clone)] +pub(crate) struct PushTransport { + pub client: reqwest::Client, + pub apns_client: reqwest::Client, + pub apns_tokens: Arc>>, + pub fcm_tokens: Arc>>, +} + +impl PushTransport { + pub fn new() -> Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(20)) + .build()?; + let apns_client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(20)) + .http2_prior_knowledge() + .build()?; + + Ok(Self { + client, + apns_client, + apns_tokens: Arc::new(RwLock::new(HashMap::new())), + fcm_tokens: Arc::new(RwLock::new(HashMap::new())), + }) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct CachedApnsToken { + pub token: String, + pub expires_at: DateTime, +} + +#[derive(Debug, Clone)] +pub(crate) struct CachedFcmToken { + pub token: String, + pub expires_at: DateTime, +} + +pub async fn enqueue_notification( + state: &Arc, + request: EnqueueNotificationRequest, +) -> Result, PushError> { + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| PushError::ConnectionError)?; + + let devices = PushDevice::list_active_for_user(&mut conn, request.user_id)?; + if devices.is_empty() { + return Ok(None); + } + + let notification_id = Uuid::new_v4(); + let payload_enc = if let Some(preview_payload) = request.preview_payload { + let payload = NotificationPreviewPayload { + v: 1, + notification_id, + message_id: preview_payload.message_id, + kind: preview_payload.kind, + title: preview_payload.title, + body: preview_payload.body, + deep_link: preview_payload.deep_link, + thread_id: preview_payload.thread_id, + sent_at: preview_payload.sent_at, + }; + let enclave_key = SecretKey::from_slice(&state.enclave_key) + .map_err(|e| PushError::InvalidSecret(e.to_string()))?; + let payload_bytes = serde_json::to_vec(&payload)?; + Some(encrypt_with_key(&enclave_key, &payload_bytes).await) + } else { + None + }; + + let event = conn.transaction::(|conn| { + let event = NewNotificationEvent { + uuid: notification_id, + project_id: request.project_id, + user_id: request.user_id, + kind: request.kind, + delivery_mode: request.delivery_mode.as_str().to_string(), + priority: request.priority.as_str().to_string(), + collapse_key: Some(format!("notif:{}", notification_id)), + fallback_title: request.fallback_title, + fallback_body: request.fallback_body, + payload_enc, + not_before_at: request.not_before_at, + expires_at: request.expires_at, + } + .insert(conn)?; + + let new_deliveries: Vec = devices + .iter() + .map(|device| NewNotificationDelivery { + event_id: event.id, + push_device_id: device.id, + next_attempt_at: event.not_before_at, + }) + .collect(); + + if new_deliveries.is_empty() { + warn!( + "Notification {} had no active push deliveries to insert", + event.uuid + ); + } else { + NewNotificationDelivery::insert_many(conn, &new_deliveries)?; + } + + Ok(event) + })?; + + Ok(Some(QueuedNotification { + notification_id: event.uuid, + delivery_count: devices.len(), + })) +} + +pub async fn enqueue_agent_message_notification( + state: &Arc, + user: &User, + target: AgentPushTarget, + message_id: Uuid, + message_text: &str, +) -> Result, PushError> { + if message_text.trim().is_empty() { + return Ok(None); + } + + let push_settings = state + .db + .get_project_push_settings(user.project_id)? + .unwrap_or_default(); + let delivery_mode = if push_settings.encrypted_preview_enabled { + PushDeliveryMode::EncryptedPreview + } else { + PushDeliveryMode::Generic + }; + + let (deep_link, thread_id) = match target { + AgentPushTarget::Main => ("opensecret://agent".to_string(), "agent:main".to_string()), + AgentPushTarget::Subagent(agent_uuid) => ( + format!("opensecret://agent/subagent/{}", agent_uuid), + format!("agent:subagent:{}", agent_uuid), + ), + }; + + enqueue_notification( + state, + EnqueueNotificationRequest { + project_id: user.project_id, + user_id: user.uuid, + kind: NOTIFICATION_KIND_AGENT_MESSAGE.to_string(), + delivery_mode, + priority: PushPriority::High, + fallback_title: AGENT_NOTIFICATION_FALLBACK_TITLE.to_string(), + fallback_body: AGENT_NOTIFICATION_FALLBACK_BODY.to_string(), + preview_payload: Some(NotificationPreviewPayloadInput { + message_id, + kind: NOTIFICATION_KIND_AGENT_MESSAGE.to_string(), + title: "Maple".to_string(), + body: normalize_preview_body(message_text), + deep_link, + thread_id, + sent_at: Utc::now().timestamp(), + }), + not_before_at: None, + expires_at: Some(Utc::now() + Duration::days(7)), + }, + ) + .await +} + +#[cfg(test)] +mod tests { + use super::{normalize_preview_body, PUSH_PREVIEW_BODY_MAX_BYTES}; + + #[test] + fn normalize_preview_body_collapses_whitespace() { + assert_eq!( + normalize_preview_body("hello\n\nthere world"), + "hello there world" + ); + } + + #[test] + fn normalize_preview_body_truncates_to_budget() { + let input = "a".repeat(PUSH_PREVIEW_BODY_MAX_BYTES + 50); + let output = normalize_preview_body(&input); + + assert!(output.len() <= PUSH_PREVIEW_BODY_MAX_BYTES); + assert!(output.ends_with('…')); + } +} diff --git a/src/push/worker.rs b/src/push/worker.rs new file mode 100644 index 00000000..e354e4a1 --- /dev/null +++ b/src/push/worker.rs @@ -0,0 +1,538 @@ +use crate::encrypt::decrypt_with_key; +use crate::models::notification_deliveries::{ + NotificationDelivery, NotificationDeliveryWriteResult, +}; +use crate::models::notification_events::NotificationEvent; +use crate::models::push_devices::{PushDevice, PUSH_PLATFORM_ANDROID, PUSH_PLATFORM_IOS}; +use crate::push::apns::{send_apns_notification, ApnsSendRequest}; +use crate::push::fcm::send_fcm_notification; +use crate::push::{NotificationPreviewPayload, PushError, PushSendOutcome, PushTransport}; +use crate::AppState; +use chrono::Utc; +use diesel::Connection; +use futures::stream::{self, StreamExt}; +use secp256k1::SecretKey; +use std::sync::Arc; +use tokio::time::{sleep, Duration as TokioDuration}; +use tracing::{debug, error}; +use uuid::Uuid; + +const PUSH_WORKER_BATCH_SIZE: i64 = 32; +const PUSH_WORKER_LEASE_TTL_SECONDS: i32 = 60; +const PUSH_WORKER_POLL_INTERVAL_SECONDS: u64 = 3; +const PUSH_WORKER_MAX_CONCURRENCY: usize = 8; +const PUSH_WORKER_MAX_ATTEMPTS: i32 = 8; + +pub fn start_push_worker(state: Arc) { + tokio::spawn(async move { + let transport = match PushTransport::new() { + Ok(transport) => transport, + Err(error) => { + error!("failed to initialize push transport: {:?}", error); + return; + } + }; + + loop { + if let Err(error) = process_push_batch(&state, &transport).await { + error!("push worker batch failed: {:?}", error); + } + + sleep(TokioDuration::from_secs(PUSH_WORKER_POLL_INTERVAL_SECONDS)).await; + } + }); +} + +async fn process_push_batch( + state: &Arc, + transport: &PushTransport, +) -> Result<(), PushError> { + let lease_owner = format!("push-worker:{}:{}", std::process::id(), Uuid::new_v4()); + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| PushError::ConnectionError)?; + let deliveries = NotificationDelivery::lease_pending( + &mut conn, + PUSH_WORKER_BATCH_SIZE, + &lease_owner, + PUSH_WORKER_LEASE_TTL_SECONDS, + )?; + drop(conn); + + if deliveries.is_empty() { + return Ok(()); + } + + debug!("leased {} push deliveries", deliveries.len()); + + stream::iter(deliveries) + .for_each_concurrent(PUSH_WORKER_MAX_CONCURRENCY, |delivery| { + let state = state.clone(); + let transport = transport.clone(); + async move { + if let Err(error) = process_leased_delivery(&state, &transport, delivery).await { + error!("push delivery processing failed: {:?}", error); + } + } + }) + .await; + + Ok(()) +} + +async fn process_leased_delivery( + state: &Arc, + transport: &PushTransport, + delivery: NotificationDelivery, +) -> Result<(), PushError> { + let Some(lease_owner) = delivery.lease_owner.clone() else { + error!( + "leased push delivery {} is missing lease_owner; waiting for lease expiry", + delivery.id + ); + return Ok(()); + }; + + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| PushError::ConnectionError)?; + let event = match NotificationEvent::get_by_id(&mut conn, delivery.event_id)? { + Some(event) => event, + None => { + if !record_delivery_transition( + NotificationDelivery::mark_failed( + &mut conn, + delivery.id, + &lease_owner, + None, + Some("event missing"), + )?, + delivery.id, + &lease_owner, + "failed (event missing)", + ) { + return Ok(()); + } + return Ok(()); + } + }; + + if event.cancelled_at.is_some() { + if !record_delivery_transition( + NotificationDelivery::mark_cancelled( + &mut conn, + delivery.id, + &lease_owner, + Some("event cancelled"), + )?, + delivery.id, + &lease_owner, + "cancelled (event cancelled)", + ) { + return Ok(()); + } + return Ok(()); + } + + if event + .expires_at + .is_some_and(|expires_at| expires_at <= Utc::now()) + { + if !record_delivery_transition( + NotificationDelivery::mark_cancelled( + &mut conn, + delivery.id, + &lease_owner, + Some("event expired"), + )?, + delivery.id, + &lease_owner, + "cancelled (event expired)", + ) { + return Ok(()); + } + return Ok(()); + } + + let device = match PushDevice::get_by_id(&mut conn, delivery.push_device_id)? { + Some(device) => device, + None => { + if !record_delivery_transition( + NotificationDelivery::mark_failed( + &mut conn, + delivery.id, + &lease_owner, + None, + Some("device missing"), + )?, + delivery.id, + &lease_owner, + "failed (device missing)", + ) { + return Ok(()); + } + return Ok(()); + } + }; + + if device.user_id != event.user_id { + if !record_delivery_transition( + NotificationDelivery::mark_cancelled( + &mut conn, + delivery.id, + &lease_owner, + Some("device user does not match event user"), + )?, + delivery.id, + &lease_owner, + "cancelled (device user mismatch)", + ) { + return Ok(()); + } + return Ok(()); + } + + if device.revoked_at.is_some() { + if !record_delivery_transition( + NotificationDelivery::mark_cancelled( + &mut conn, + delivery.id, + &lease_owner, + Some("device revoked"), + )?, + delivery.id, + &lease_owner, + "cancelled (device revoked)", + ) { + return Ok(()); + } + return Ok(()); + } + drop(conn); + + let send_outcome = match build_send_outcome(state, transport, &event, &device).await { + Ok(outcome) => outcome, + Err(error) => { + error!( + "push delivery {} encountered internal send error before provider outcome: {:?}", + delivery.id, error + ); + classify_internal_push_error(error) + } + }; + + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| PushError::ConnectionError)?; + match send_outcome { + PushSendOutcome::Sent { + provider_message_id, + provider_status_code, + } => { + record_delivery_transition( + NotificationDelivery::mark_sent( + &mut conn, + delivery.id, + &lease_owner, + provider_message_id.as_deref(), + provider_status_code, + )?, + delivery.id, + &lease_owner, + "sent", + ); + } + PushSendOutcome::Retryable { + provider_status_code, + error, + } => { + if delivery.attempt_count + 1 >= PUSH_WORKER_MAX_ATTEMPTS { + record_delivery_transition( + NotificationDelivery::mark_failed( + &mut conn, + delivery.id, + &lease_owner, + provider_status_code, + Some(&error), + )?, + delivery.id, + &lease_owner, + "failed (retry limit reached)", + ); + } else { + record_delivery_transition( + NotificationDelivery::mark_retry( + &mut conn, + delivery.id, + &lease_owner, + provider_status_code, + Some(&error), + retry_backoff_seconds(delivery.attempt_count + 1), + )?, + delivery.id, + &lease_owner, + "retry", + ); + } + } + PushSendOutcome::InvalidToken { + provider_status_code, + error, + } => { + let invalidated = conn.transaction::(|conn| { + let write_result = NotificationDelivery::mark_invalid_token( + conn, + delivery.id, + &lease_owner, + provider_status_code, + Some(&error), + )?; + + if write_result.was_applied() { + PushDevice::invalidate(conn, device.id)?; + Ok(true) + } else { + Ok(false) + } + })?; + + if !invalidated { + debug!( + "push delivery {} lost lease before marking invalid_token (lease_owner={})", + delivery.id, lease_owner + ); + } + } + PushSendOutcome::Failed { + provider_status_code, + error, + } => { + record_delivery_transition( + NotificationDelivery::mark_failed( + &mut conn, + delivery.id, + &lease_owner, + provider_status_code, + Some(&error), + )?, + delivery.id, + &lease_owner, + "failed", + ); + } + } + + Ok(()) +} + +async fn build_send_outcome( + state: &Arc, + transport: &PushTransport, + event: &NotificationEvent, + device: &PushDevice, +) -> Result { + let preview_payload = decrypt_preview_payload(state, event)?; + let push_token = decrypt_push_token(state, device)?; + + dispatch_delivery( + state, + transport, + event, + device, + &push_token, + preview_payload.as_ref(), + ) + .await +} + +async fn dispatch_delivery( + state: &Arc, + transport: &PushTransport, + event: &NotificationEvent, + device: &PushDevice, + push_token: &str, + preview_payload: Option<&NotificationPreviewPayload>, +) -> Result { + let push_settings = state + .db + .get_project_push_settings(event.project_id)? + .unwrap_or_default(); + + match device.platform.as_str() { + PUSH_PLATFORM_IOS => { + let Some(ios_settings) = push_settings + .ios + .as_ref() + .filter(|settings| settings.enabled) + else { + return Ok(PushSendOutcome::Failed { + provider_status_code: None, + error: "iOS push is not configured for this project".to_string(), + }); + }; + + if device.app_id != ios_settings.bundle_id { + return Ok(PushSendOutcome::Failed { + provider_status_code: None, + error: "device app_id does not match configured iOS bundle_id".to_string(), + }); + } + + if device.environment != ios_settings.apns_environment.as_str() { + return Ok(PushSendOutcome::Failed { + provider_status_code: None, + error: "device environment does not match project APNs environment".to_string(), + }); + } + + let send_encrypted_preview = push_settings.encrypted_preview_enabled + && event.delivery_mode == "encrypted_preview" + && preview_payload.is_some() + && device.supports_encrypted_preview; + + send_apns_notification( + state, + transport, + ApnsSendRequest { + event, + device, + push_token, + ios_settings, + preview_payload, + send_encrypted_preview, + }, + ) + .await + } + PUSH_PLATFORM_ANDROID => { + let Some(android_settings) = push_settings + .android + .as_ref() + .filter(|settings| settings.enabled) + else { + return Ok(PushSendOutcome::Failed { + provider_status_code: None, + error: "Android push is not configured for this project".to_string(), + }); + }; + + if device.app_id != android_settings.package_name { + return Ok(PushSendOutcome::Failed { + provider_status_code: None, + error: "device app_id does not match configured Android package_name" + .to_string(), + }); + } + + send_fcm_notification( + state, + transport, + event, + device, + push_token, + android_settings, + preview_payload, + ) + .await + } + _ => Ok(PushSendOutcome::Failed { + provider_status_code: None, + error: format!("unsupported push platform: {}", device.platform), + }), + } +} + +fn decrypt_preview_payload( + state: &Arc, + event: &NotificationEvent, +) -> Result, PushError> { + let Some(payload_enc) = &event.payload_enc else { + return Ok(None); + }; + + let secret_key = SecretKey::from_slice(&state.enclave_key) + .map_err(|e| PushError::InvalidSecret(e.to_string()))?; + let plaintext = decrypt_with_key(&secret_key, payload_enc) + .map_err(|e| PushError::CryptoError(e.to_string()))?; + let payload = serde_json::from_slice::(&plaintext)?; + + Ok(Some(payload)) +} + +fn decrypt_push_token(state: &Arc, device: &PushDevice) -> Result { + let secret_key = SecretKey::from_slice(&state.enclave_key) + .map_err(|e| PushError::InvalidSecret(e.to_string()))?; + let plaintext = decrypt_with_key(&secret_key, &device.push_token_enc) + .map_err(|e| PushError::CryptoError(e.to_string()))?; + + String::from_utf8(plaintext).map_err(|e| PushError::InvalidSecret(e.to_string())) +} + +fn classify_internal_push_error(error: PushError) -> PushSendOutcome { + match error { + PushError::ConnectionError + | PushError::DatabaseError(_) + | PushError::DbError(_) + | PushError::HttpError(_) + | PushError::ProviderRetryable(_) => PushSendOutcome::Retryable { + provider_status_code: None, + error: error.to_string(), + }, + _ => PushSendOutcome::Failed { + provider_status_code: None, + error: error.to_string(), + }, + } +} + +fn record_delivery_transition( + write_result: NotificationDeliveryWriteResult, + delivery_id: i64, + lease_owner: &str, + transition: &str, +) -> bool { + if write_result.was_applied() { + true + } else { + debug!( + "push delivery {} lost lease before marking {} (lease_owner={})", + delivery_id, transition, lease_owner + ); + false + } +} + +fn retry_backoff_seconds(attempt_count: i32) -> i32 { + let capped_attempt = attempt_count.clamp(1, 6); + let seconds = 15_i64 * (1_i64 << (capped_attempt - 1)); + seconds.min(15 * 60) as i32 +} + +#[cfg(test)] +mod tests { + use super::{classify_internal_push_error, retry_backoff_seconds}; + use crate::push::{PushError, PushSendOutcome}; + + #[test] + fn classifies_retryable_provider_errors_as_retryable() { + match classify_internal_push_error(PushError::ProviderRetryable( + "temporary provider failure".to_string(), + )) { + PushSendOutcome::Retryable { .. } => {} + outcome => panic!("expected retryable outcome, got {outcome:?}"), + } + } + + #[test] + fn retry_backoff_is_capped() { + assert_eq!(retry_backoff_seconds(1), 15); + assert_eq!(retry_backoff_seconds(6), 480); + assert_eq!(retry_backoff_seconds(10), 480); + } +} diff --git a/src/rag.rs b/src/rag.rs new file mode 100644 index 00000000..21380403 --- /dev/null +++ b/src/rag.rs @@ -0,0 +1,1125 @@ +use crate::encrypt::{decrypt_with_key, encrypt_key_deterministic, encrypt_with_key}; +use crate::models::schema::user_embeddings; +use crate::models::user_embeddings::NewUserEmbedding; +use crate::models::users::User; +use crate::web::openai_auth::AuthMethod; +use crate::{ApiError, AppState}; +use base64::{engine::general_purpose::STANDARD as B64_STANDARD, Engine as _}; +use diesel::prelude::*; +use secp256k1::SecretKey; +use serde::Serialize; +use std::cmp::Ordering; +use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tracing::{debug, error}; +use uuid::Uuid; + +pub const DEFAULT_EMBEDDING_MODEL: &str = "nomic-embed-text"; +pub const DEFAULT_EMBEDDING_DIM: i32 = 768; + +const CACHE_MAX_USERS: usize = 100; +const CACHE_TTL: Duration = Duration::from_secs(5 * 60); + +const DB_SCAN_BATCH_SIZE: i64 = 1000; + +#[allow(dead_code)] +pub const SOURCE_TYPE_MESSAGE: &str = "message"; +pub const SOURCE_TYPE_ARCHIVAL: &str = "archival"; + +#[derive(Debug, Clone, Serialize)] +pub struct RagSearchResult { + pub content: String, + pub score: f32, + pub token_count: i32, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RagEmbeddingsStatus { + pub total_embeddings: i64, + pub by_model: HashMap, + pub stale_count: i64, +} + +#[derive(Debug, Clone)] +pub struct CachedEmbedding { + pub source_type: String, + pub conversation_id: Option, + pub vector: Vec, + pub content_enc: Vec, + pub token_count: i32, +} + +#[derive(Debug, Clone)] +struct CacheEntry { + loaded_at: Instant, + embeddings: Arc>, +} + +#[derive(Debug)] +pub struct RagCache { + max_users: usize, + ttl: Duration, + entries: HashMap, + lru: VecDeque, +} + +impl Default for RagCache { + fn default() -> Self { + Self::new(CACHE_MAX_USERS, CACHE_TTL) + } +} + +impl RagCache { + pub fn new(max_users: usize, ttl: Duration) -> Self { + Self { + max_users, + ttl, + entries: HashMap::new(), + lru: VecDeque::new(), + } + } + + pub fn evict_user(&mut self, user_id: Uuid) { + self.entries.remove(&user_id); + self.lru.retain(|u| *u != user_id); + } + + pub fn get(&mut self, user_id: Uuid) -> Option>> { + self.evict_expired(); + + let (loaded_at, embeddings) = { + let entry = self.entries.get(&user_id)?; + (entry.loaded_at, entry.embeddings.clone()) + }; + + if loaded_at.elapsed() > self.ttl { + self.evict_user(user_id); + return None; + } + + self.touch(user_id); + Some(embeddings) + } + + pub fn put(&mut self, user_id: Uuid, embeddings: Arc>) { + self.entries.insert( + user_id, + CacheEntry { + loaded_at: Instant::now(), + embeddings, + }, + ); + self.touch(user_id); + + while self.entries.len() > self.max_users { + if let Some(lru_user) = self.lru.pop_back() { + self.entries.remove(&lru_user); + } else { + break; + } + } + } + + fn touch(&mut self, user_id: Uuid) { + self.lru.retain(|u| *u != user_id); + self.lru.push_front(user_id); + } + + fn evict_expired(&mut self) { + let ttl = self.ttl; + let expired: Vec = self + .entries + .iter() + .filter_map(|(user_id, entry)| { + if entry.loaded_at.elapsed() > ttl { + Some(*user_id) + } else { + None + } + }) + .collect(); + + for user_id in expired { + self.evict_user(user_id); + } + } +} + +pub fn serialize_f32_le(values: &[f32]) -> Vec { + let mut out = Vec::with_capacity(values.len() * 4); + for v in values { + out.extend_from_slice(&v.to_le_bytes()); + } + out +} + +pub fn deserialize_f32_le(bytes: &[u8]) -> Result, ApiError> { + if !bytes.len().is_multiple_of(4) { + return Err(ApiError::BadRequest); + } + + let mut out = Vec::with_capacity(bytes.len() / 4); + for chunk in bytes.chunks_exact(4) { + let arr: [u8; 4] = chunk.try_into().map_err(|_| ApiError::BadRequest)?; + out.push(f32::from_le_bytes(arr)); + } + Ok(out) +} + +pub fn cosine_similarity(a: &[f32], b: &[f32]) -> Result { + if a.len() != b.len() { + return Err(ApiError::BadRequest); + } + + let mut dot = 0.0f32; + let mut norm_a = 0.0f32; + let mut norm_b = 0.0f32; + + for (x, y) in a.iter().zip(b.iter()) { + dot += x * y; + norm_a += x * x; + norm_b += y * y; + } + + if norm_a == 0.0 || norm_b == 0.0 { + return Ok(0.0); + } + + Ok(dot / (norm_a.sqrt() * norm_b.sqrt())) +} + +#[derive(Debug, Clone)] +struct HeapItem { + score: f32, + token_count: i32, + content_enc: Vec, +} + +impl Eq for HeapItem {} + +impl PartialEq for HeapItem { + fn eq(&self, other: &Self) -> bool { + self.score.to_bits() == other.score.to_bits() && self.token_count == other.token_count + } +} + +impl Ord for HeapItem { + fn cmp(&self, other: &Self) -> Ordering { + self.score + .total_cmp(&other.score) + .then_with(|| other.token_count.cmp(&self.token_count)) + } +} + +impl PartialOrd for HeapItem { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +fn top_k_candidates( + query: &[f32], + embeddings: &[CachedEmbedding], + top_k: usize, + source_types: Option<&[String]>, + conversation_id: Option, +) -> Result, ApiError> { + let allowed_source_types: Option> = source_types.map(|v| { + v.iter() + .map(|s| s.as_str()) + .collect::>() + }); + + let mut heap: BinaryHeap> = BinaryHeap::new(); + + for e in embeddings { + if let Some(allowed) = &allowed_source_types { + if !allowed.contains(e.source_type.as_str()) { + continue; + } + } + + if let Some(conv_id) = conversation_id { + if e.conversation_id != Some(conv_id) { + continue; + } + } + + if e.vector.len() != query.len() { + continue; + } + + let score = cosine_similarity(query, &e.vector)?; + let item = HeapItem { + score, + token_count: e.token_count, + content_enc: e.content_enc.clone(), + }; + + if heap.len() < top_k { + heap.push(std::cmp::Reverse(item)); + continue; + } + + if let Some(std::cmp::Reverse(min)) = heap.peek() { + if item.cmp(min) == Ordering::Greater { + heap.pop(); + heap.push(std::cmp::Reverse(item)); + } + } + } + + let mut out: Vec = heap.into_iter().map(|r| r.0).collect(); + out.sort_by(|a, b| b.cmp(a)); + Ok(out) +} + +fn apply_token_budget(results: Vec, budget: i32) -> Vec { + let mut total: i32 = 0; + let mut limited: Vec = Vec::new(); + for r in results { + if total + r.token_count > budget { + break; + } + total += r.token_count; + limited.push(r); + } + limited +} + +async fn embed_text_via_tinfoil( + state: &Arc, + user: &User, + auth_method: AuthMethod, + text: &str, +) -> Result<(Vec, i32), ApiError> { + crate::web::get_embedding_vector( + state, + user, + auth_method, + DEFAULT_EMBEDDING_MODEL, + text, + Some(DEFAULT_EMBEDDING_DIM), + ) + .await +} + +fn normalize_tags<'a>(tags: impl Iterator) -> Vec { + let mut seen: HashSet = HashSet::new(); + let mut out: Vec = Vec::new(); + + for tag in tags { + let normalized = tag.trim().to_lowercase(); + if normalized.is_empty() { + continue; + } + + if seen.insert(normalized.clone()) { + out.push(normalized); + } + } + + out +} + +fn encrypt_tags_b64(user_key: &SecretKey, tags: &[String]) -> Vec> { + tags.iter() + .map(|tag| { + let ciphertext = encrypt_key_deterministic(user_key, tag.as_bytes()); + Some(B64_STANDARD.encode(ciphertext)) + }) + .collect() +} + +fn extract_tags_from_metadata(metadata: Option<&serde_json::Value>) -> Vec { + let Some(metadata) = metadata else { + return Vec::new(); + }; + + let Some(tags) = metadata.get("tags") else { + return Vec::new(); + }; + + match tags { + serde_json::Value::Array(arr) => normalize_tags(arr.iter().filter_map(|v| v.as_str())), + serde_json::Value::String(s) => normalize_tags(s.split(',')), + _ => Vec::new(), + } +} + +pub async fn insert_archival_embedding( + state: &Arc, + user: &User, + auth_method: AuthMethod, + user_key: &SecretKey, + text: &str, + metadata: Option<&serde_json::Value>, +) -> Result { + let user_id = user.uuid; + let (vector, token_count) = embed_text_via_tinfoil(state, user, auth_method, text).await?; + + let vector_bytes = serialize_f32_le(&vector); + let vector_enc = encrypt_with_key(user_key, &vector_bytes).await; + let content_enc = encrypt_with_key(user_key, text.as_bytes()).await; + + let metadata_enc = if let Some(m) = metadata { + let m_bytes = serde_json::to_vec(m).map_err(|_| ApiError::BadRequest)?; + Some(encrypt_with_key(user_key, &m_bytes).await) + } else { + None + }; + + let tags = extract_tags_from_metadata(metadata); + let tags_enc = encrypt_tags_b64(user_key, &tags); + + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + let inserted = NewUserEmbedding { + uuid: Uuid::new_v4(), + user_id, + source_type: SOURCE_TYPE_ARCHIVAL.to_string(), + user_message_id: None, + assistant_message_id: None, + conversation_id: None, + vector_enc, + embedding_model: DEFAULT_EMBEDDING_MODEL.to_string(), + vector_dim: DEFAULT_EMBEDDING_DIM, + content_enc, + metadata_enc, + tags_enc, + token_count, + } + .insert(&mut conn) + .map_err(|e| { + error!("Failed to insert archival embedding: {:?}", e); + ApiError::InternalServerError + })?; + + state.rag_cache.lock().await.evict_user(user_id); + Ok(inserted) +} + +#[allow(clippy::too_many_arguments)] +pub async fn insert_message_embedding( + state: &Arc, + user: &User, + auth_method: AuthMethod, + user_key: &SecretKey, + text: &str, + conversation_id: i64, + user_message_id: Option, + assistant_message_id: Option, +) -> Result { + let user_id = user.uuid; + let text = text.trim(); + if text.is_empty() { + return Err(ApiError::BadRequest); + } + + let (vector, token_count) = embed_text_via_tinfoil(state, user, auth_method, text).await?; + + let vector_bytes = serialize_f32_le(&vector); + let vector_enc = encrypt_with_key(user_key, &vector_bytes).await; + let content_enc = encrypt_with_key(user_key, text.as_bytes()).await; + + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + let inserted = NewUserEmbedding { + uuid: Uuid::new_v4(), + user_id, + source_type: SOURCE_TYPE_MESSAGE.to_string(), + user_message_id, + assistant_message_id, + conversation_id: Some(conversation_id), + vector_enc, + embedding_model: DEFAULT_EMBEDDING_MODEL.to_string(), + vector_dim: DEFAULT_EMBEDDING_DIM, + content_enc, + metadata_enc: None, + tags_enc: Vec::new(), + token_count, + } + .insert(&mut conn) + .map_err(|e| { + error!("Failed to insert message embedding: {:?}", e); + ApiError::InternalServerError + })?; + + state.rag_cache.lock().await.evict_user(user_id); + Ok(inserted) +} + +async fn load_all_user_embeddings( + state: &AppState, + user_id: Uuid, + user_key: &SecretKey, +) -> Result>, ApiError> { + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + let mut last_id: i64 = 0; + let mut out: Vec = Vec::new(); + + #[derive(Queryable)] + struct EmbeddingScanRow { + source_type: String, + conversation_id: Option, + vector_enc: Vec, + content_enc: Vec, + token_count: i32, + vector_dim: i32, + id: i64, + } + + loop { + let rows: Vec = user_embeddings::table + .filter(user_embeddings::user_id.eq(user_id)) + .filter(user_embeddings::id.gt(last_id)) + .order(user_embeddings::id.asc()) + .select(( + user_embeddings::source_type, + user_embeddings::conversation_id, + user_embeddings::vector_enc, + user_embeddings::content_enc, + user_embeddings::token_count, + user_embeddings::vector_dim, + user_embeddings::id, + )) + .limit(DB_SCAN_BATCH_SIZE) + .load(&mut conn) + .map_err(|e| { + error!( + "Failed to load embeddings batch for user={} after id={}: {:?}", + user_id, last_id, e + ); + ApiError::InternalServerError + })?; + + if rows.is_empty() { + break; + } + + for row in rows { + let EmbeddingScanRow { + source_type, + conversation_id, + vector_enc, + content_enc, + token_count, + vector_dim, + id, + } = row; + + let vector_bytes = decrypt_with_key(user_key, &vector_enc) + .map_err(|_| ApiError::InternalServerError)?; + let vector = deserialize_f32_le(&vector_bytes)?; + + if vector.len() != vector_dim as usize { + debug!( + "Skipping embedding id={} for user={} due to dim mismatch (expected={}, got={})", + id, + user_id, + vector_dim, + vector.len() + ); + continue; + } + + out.push(CachedEmbedding { + source_type, + conversation_id, + vector, + content_enc, + token_count, + }); + last_id = id; + } + } + + Ok(Arc::new(out)) +} + +async fn load_user_embeddings_by_tags( + state: &AppState, + user_id: Uuid, + user_key: &SecretKey, + source_types: Option<&[String]>, + conversation_id: Option, + tags_enc_filter: &[Option], +) -> Result>, ApiError> { + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + let tags_enc_filter = tags_enc_filter.to_vec(); + + let mut last_id: i64 = 0; + let mut out: Vec = Vec::new(); + + #[derive(Queryable)] + struct EmbeddingScanRow { + source_type: String, + conversation_id: Option, + vector_enc: Vec, + content_enc: Vec, + token_count: i32, + vector_dim: i32, + id: i64, + } + + loop { + let mut query = user_embeddings::table + .filter(user_embeddings::user_id.eq(user_id)) + .filter(user_embeddings::id.gt(last_id)) + .filter(user_embeddings::tags_enc.overlaps_with(tags_enc_filter.clone())) + .into_boxed(); + + if let Some(source_types) = source_types { + query = query.filter(user_embeddings::source_type.eq_any(source_types)); + } + + if let Some(conversation_id) = conversation_id { + query = query.filter(user_embeddings::conversation_id.eq(Some(conversation_id))); + } + + let rows: Vec = query + .order(user_embeddings::id.asc()) + .select(( + user_embeddings::source_type, + user_embeddings::conversation_id, + user_embeddings::vector_enc, + user_embeddings::content_enc, + user_embeddings::token_count, + user_embeddings::vector_dim, + user_embeddings::id, + )) + .limit(DB_SCAN_BATCH_SIZE) + .load(&mut conn) + .map_err(|e| { + error!( + "Failed to load tag-filtered embeddings batch for user={} after id={}: {:?}", + user_id, last_id, e + ); + ApiError::InternalServerError + })?; + + if rows.is_empty() { + break; + } + + for row in rows { + let EmbeddingScanRow { + source_type, + conversation_id, + vector_enc, + content_enc, + token_count, + vector_dim, + id, + } = row; + + let vector_bytes = decrypt_with_key(user_key, &vector_enc) + .map_err(|_| ApiError::InternalServerError)?; + let vector = deserialize_f32_le(&vector_bytes)?; + + if vector.len() != vector_dim as usize { + debug!( + "Skipping embedding id={} for user={} due to dim mismatch (expected={}, got={})", + id, + user_id, + vector_dim, + vector.len() + ); + continue; + } + + out.push(CachedEmbedding { + source_type, + conversation_id, + vector, + content_enc, + token_count, + }); + last_id = id; + } + } + + Ok(Arc::new(out)) +} + +#[allow(clippy::too_many_arguments)] +pub async fn search_user_embeddings( + state: &Arc, + user: &User, + auth_method: AuthMethod, + user_key: &SecretKey, + query: &str, + top_k: usize, + max_tokens: Option, + source_types: Option<&[String]>, + conversation_id: Option, + tags: Option<&[String]>, +) -> Result, ApiError> { + let top_k = top_k.clamp(1, 20); + + let user_id = user.uuid; + + let (query_vec, _query_tokens) = + embed_text_via_tinfoil(state, user, auth_method, query).await?; + + let tags_enc_filter = tags + .map(|t| normalize_tags(t.iter().map(|s| s.as_str()))) + .filter(|t| !t.is_empty()) + .map(|t| encrypt_tags_b64(user_key, &t)); + + let embeddings = if let Some(tags_enc_filter) = tags_enc_filter.as_ref() { + load_user_embeddings_by_tags( + state, + user_id, + user_key, + source_types, + conversation_id, + tags_enc_filter, + ) + .await? + } else { + let cached = { + let mut cache = state.rag_cache.lock().await; + cache.get(user_id) + }; + + if let Some(hit) = cached { + hit + } else { + let loaded = load_all_user_embeddings(state, user_id, user_key).await?; + state.rag_cache.lock().await.put(user_id, loaded.clone()); + loaded + } + }; + + let candidates = top_k_candidates( + &query_vec, + &embeddings, + top_k, + source_types, + conversation_id, + )?; + + let mut results: Vec = Vec::with_capacity(candidates.len()); + for c in candidates { + let plaintext = decrypt_with_key(user_key, &c.content_enc) + .map_err(|_| ApiError::InternalServerError)?; + let content = String::from_utf8(plaintext).map_err(|_| ApiError::InternalServerError)?; + results.push(RagSearchResult { + content, + score: c.score, + token_count: c.token_count, + }); + } + + if let Some(budget) = max_tokens { + results = apply_token_budget(results, budget); + } + + Ok(results) +} + +pub async fn delete_all_user_embeddings(state: &AppState, user_id: Uuid) -> Result<(), ApiError> { + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + diesel::delete(user_embeddings::table.filter(user_embeddings::user_id.eq(user_id))) + .execute(&mut conn) + .map_err(|e| { + error!( + "Failed to delete all embeddings for user={}: {:?}", + user_id, e + ); + ApiError::InternalServerError + })?; + + state.rag_cache.lock().await.evict_user(user_id); + Ok(()) +} + +pub async fn delete_user_embedding_by_uuid( + state: &AppState, + user_id: Uuid, + embedding_uuid: Uuid, +) -> Result<(), ApiError> { + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + let affected = diesel::delete( + user_embeddings::table + .filter(user_embeddings::user_id.eq(user_id)) + .filter(user_embeddings::uuid.eq(embedding_uuid)), + ) + .execute(&mut conn) + .map_err(|e| { + error!( + "Failed to delete embedding user={} uuid={}: {:?}", + user_id, embedding_uuid, e + ); + ApiError::InternalServerError + })?; + + if affected == 0 { + return Err(ApiError::NotFound); + } + + state.rag_cache.lock().await.evict_user(user_id); + Ok(()) +} + +pub async fn embeddings_status( + state: &AppState, + user_id: Uuid, +) -> Result { + use diesel::dsl::count_star; + + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + let total_embeddings: i64 = user_embeddings::table + .filter(user_embeddings::user_id.eq(user_id)) + .select(count_star()) + .first(&mut conn) + .map_err(|e| { + error!( + "Failed to count embeddings for user={} (total): {:?}", + user_id, e + ); + ApiError::InternalServerError + })?; + + let grouped: Vec<(String, i64)> = user_embeddings::table + .filter(user_embeddings::user_id.eq(user_id)) + .group_by(user_embeddings::embedding_model) + .select((user_embeddings::embedding_model, count_star())) + .load(&mut conn) + .map_err(|e| { + error!( + "Failed to group embeddings by model for user={}: {:?}", + user_id, e + ); + ApiError::InternalServerError + })?; + + let mut by_model: HashMap = HashMap::new(); + for (model, count) in grouped { + by_model.insert(model, count); + } + + let stale_count: i64 = user_embeddings::table + .filter(user_embeddings::user_id.eq(user_id)) + .filter(user_embeddings::embedding_model.ne(DEFAULT_EMBEDDING_MODEL)) + .select(count_star()) + .first(&mut conn) + .map_err(|e| { + error!( + "Failed to count stale embeddings for user={}: {:?}", + user_id, e + ); + ApiError::InternalServerError + })?; + + Ok(RagEmbeddingsStatus { + total_embeddings, + by_model, + stale_count, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serialize_deserialize_roundtrip() { + let v = vec![0.0f32, 1.5, -2.25, 42.0]; + let bytes = serialize_f32_le(&v); + let decoded = deserialize_f32_le(&bytes).unwrap(); + assert_eq!(v, decoded); + } + + #[tokio::test] + async fn vector_encrypt_roundtrip() { + let key = SecretKey::from_slice(&[7u8; 32]).unwrap(); + let v = vec![0.0f32, 1.5, -2.25, 42.0]; + + let bytes = serialize_f32_le(&v); + let enc = encrypt_with_key(&key, &bytes).await; + let dec = decrypt_with_key(&key, &enc).unwrap(); + let decoded = deserialize_f32_le(&dec).unwrap(); + assert_eq!(v, decoded); + } + + #[test] + fn cosine_similarity_known_values() { + let a = vec![1.0f32, 0.0, 0.0]; + let b = vec![1.0f32, 0.0, 0.0]; + let c = vec![0.0f32, 1.0, 0.0]; + + assert!((cosine_similarity(&a, &b).unwrap() - 1.0).abs() < 1e-6); + assert!((cosine_similarity(&a, &c).unwrap() - 0.0).abs() < 1e-6); + } + + #[test] + fn cosine_similarity_mismatched_dimensions_errors() { + let a = vec![1.0f32, 0.0]; + let b = vec![1.0f32]; + assert!(matches!( + cosine_similarity(&a, &b), + Err(ApiError::BadRequest) + )); + } + + #[test] + fn cosine_similarity_zero_vectors_return_zero() { + let zero = vec![0.0f32, 0.0, 0.0]; + let nonzero = vec![1.0f32, 2.0, 3.0]; + + assert_eq!(cosine_similarity(&zero, &nonzero).unwrap(), 0.0); + assert_eq!(cosine_similarity(&nonzero, &zero).unwrap(), 0.0); + assert_eq!(cosine_similarity(&zero, &zero).unwrap(), 0.0); + } + + #[test] + fn deserialize_invalid_byte_length_errors() { + let bytes = vec![0u8; 3]; + assert!(matches!( + deserialize_f32_le(&bytes), + Err(ApiError::BadRequest) + )); + } + + #[test] + fn apply_token_budget_is_prefix() { + let results = vec![ + RagSearchResult { + content: "a".to_string(), + score: 1.0, + token_count: 6, + }, + RagSearchResult { + content: "b".to_string(), + score: 0.9, + token_count: 6, + }, + RagSearchResult { + content: "c".to_string(), + score: 0.8, + token_count: 1, + }, + ]; + + let limited = apply_token_budget(results, 10); + assert_eq!(limited.len(), 1); + assert_eq!(limited[0].content, "a"); + } + + #[test] + fn apply_token_budget_exact_fit_keeps_all() { + let results = vec![ + RagSearchResult { + content: "a".to_string(), + score: 1.0, + token_count: 3, + }, + RagSearchResult { + content: "b".to_string(), + score: 0.9, + token_count: 7, + }, + ]; + + let limited = apply_token_budget(results, 10); + assert_eq!(limited.len(), 2); + } + + #[test] + fn apply_token_budget_zero_budget_returns_empty() { + let results = vec![RagSearchResult { + content: "a".to_string(), + score: 1.0, + token_count: 1, + }]; + + let limited = apply_token_budget(results, 0); + assert!(limited.is_empty()); + } + + #[test] + fn top_k_heap_ranking_and_filters() { + let query = vec![1.0f32, 0.0]; + + let embeddings = vec![ + CachedEmbedding { + source_type: SOURCE_TYPE_ARCHIVAL.to_string(), + conversation_id: None, + vector: vec![1.0, 0.0], + content_enc: b"a".to_vec(), + token_count: 5, + }, + CachedEmbedding { + source_type: SOURCE_TYPE_MESSAGE.to_string(), + conversation_id: Some(123), + vector: vec![0.0, 1.0], + content_enc: b"b".to_vec(), + token_count: 7, + }, + CachedEmbedding { + source_type: SOURCE_TYPE_MESSAGE.to_string(), + conversation_id: Some(123), + vector: vec![0.8, 0.2], + content_enc: b"c".to_vec(), + token_count: 9, + }, + ]; + + let items = top_k_candidates( + &query, + &embeddings, + 2, + Some(&[SOURCE_TYPE_MESSAGE.to_string()]), + Some(123), + ) + .unwrap(); + + assert_eq!(items.len(), 2); + assert!(items[0].score >= items[1].score); + assert_eq!(items[0].content_enc, b"c"); + assert_eq!(items[1].content_enc, b"b"); + } + + #[test] + fn top_k_tie_break_prefers_fewer_tokens() { + let query = vec![1.0f32, 0.0]; + + let embeddings = vec![ + CachedEmbedding { + source_type: SOURCE_TYPE_ARCHIVAL.to_string(), + conversation_id: None, + vector: vec![1.0, 0.0], + content_enc: b"a".to_vec(), + token_count: 10, + }, + CachedEmbedding { + source_type: SOURCE_TYPE_ARCHIVAL.to_string(), + conversation_id: None, + vector: vec![1.0, 0.0], + content_enc: b"b".to_vec(), + token_count: 5, + }, + ]; + + let items = top_k_candidates(&query, &embeddings, 1, None, None).unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].content_enc, b"b"); + } + + #[test] + fn top_k_when_k_gt_embeddings_returns_all() { + let query = vec![1.0f32, 0.0]; + + let embeddings = vec![ + CachedEmbedding { + source_type: SOURCE_TYPE_ARCHIVAL.to_string(), + conversation_id: None, + vector: vec![1.0, 0.0], + content_enc: b"a".to_vec(), + token_count: 5, + }, + CachedEmbedding { + source_type: SOURCE_TYPE_MESSAGE.to_string(), + conversation_id: Some(123), + vector: vec![0.0, 1.0], + content_enc: b"b".to_vec(), + token_count: 7, + }, + ]; + + let total = embeddings.len(); + let items = top_k_candidates(&query, &embeddings, 10, None, None).unwrap(); + assert_eq!(items.len(), total); + } + + #[test] + fn top_k_with_empty_embeddings_returns_empty() { + let query = vec![1.0f32, 0.0]; + let embeddings: Vec = vec![]; + let items = top_k_candidates(&query, &embeddings, 10, None, None).unwrap(); + assert!(items.is_empty()); + } + + #[test] + fn rag_cache_evict_user_removes_entry() { + let mut cache = RagCache::new(10, Duration::from_secs(60)); + let user_id = Uuid::new_v4(); + + cache.put(user_id, Arc::new(vec![])); + assert!(cache.entries.contains_key(&user_id)); + + cache.evict_user(user_id); + assert!(!cache.entries.contains_key(&user_id)); + assert!(!cache.lru.contains(&user_id)); + assert!(cache.get(user_id).is_none()); + } + + #[tokio::test] + async fn rag_cache_lru_eviction() { + let mut cache = RagCache::new(2, Duration::from_secs(60)); + + let v1 = Arc::new(vec![]); + let v2 = Arc::new(vec![]); + let v3 = Arc::new(vec![]); + + let u1 = Uuid::new_v4(); + let u2 = Uuid::new_v4(); + let u3 = Uuid::new_v4(); + + cache.put(u1, v1); + cache.put(u2, v2); + // touch u1 so u2 becomes LRU + cache.get(u1); + cache.put(u3, v3); + + assert!(cache.entries.contains_key(&u1)); + assert!(!cache.entries.contains_key(&u2)); + assert!(cache.entries.contains_key(&u3)); + } + + #[tokio::test] + async fn rag_cache_ttl_expiration() { + let mut cache = RagCache::new(10, Duration::from_millis(5)); + let user = Uuid::new_v4(); + + cache.put(user, Arc::new(vec![])); + tokio::time::sleep(Duration::from_millis(10)).await; + assert!(cache.get(user).is_none()); + } +} diff --git a/src/web/agent/compaction.rs b/src/web/agent/compaction.rs new file mode 100644 index 00000000..ab59aab6 --- /dev/null +++ b/src/web/agent/compaction.rs @@ -0,0 +1,247 @@ +use crate::ApiError; +use dspy_rs::adapter::chat::ChatAdapter; +use dspy_rs::LM; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tracing::error; + +/// Instruction for summarization DSRs signature +pub const SUMMARY_INSTRUCTION: &str = r#"You are a conversation summarizer. Your job is to create a concise summary that allows an AI agent to resume a conversation without disruption, even after older messages are replaced with this summary. + +Your summary should be structured and actionable. Include: +1. Task/Conversation overview: What is the user working on? Key clarifications or constraints? +2. Current State: What has been completed or discussed? Any files/resources referenced? +3. Next Steps: What would logically come next in this conversation? + +Keep your summary under 100 words. Be specific and preserve key details like names, preferences, and decisions made."#; + +/// Instruction for correction DSRs signature +pub const SUMMARY_CORRECTION_INSTRUCTION: &str = r#"You are a correction agent. The summarizer produced a malformed response that couldn't be parsed. Your job is to extract the summary from the malformed response and return it in the correct format. + +Preserve the original intent and content - do NOT generate new content. Just reshape the malformed response into the expected output format."#; + +/// DSRs signature for conversation summarization +#[derive(dspy_rs::Signature, Clone, Debug)] +pub struct SummarizeConversation { + #[input(desc = "Previous summary to build upon (empty if first summarization)")] + pub previous_summary: String, + + #[input(desc = "New conversation messages to incorporate into the summary")] + pub new_messages: String, + + #[output(desc = "Updated summary incorporating all context (100 word limit)")] + pub summary: String, +} + +/// DSRs signature for correcting malformed summarization responses +#[derive(dspy_rs::Signature, Clone, Debug)] +pub struct SummarizationCorrection { + #[input(desc = "The previous summary that was being built upon")] + pub previous_summary: String, + + #[input(desc = "The new messages that were being summarized")] + pub new_messages: String, + + #[input(desc = "The malformed response that needs correction")] + pub malformed_response: String, + + #[input(desc = "The error message explaining what went wrong")] + pub error_message: String, + + #[output(desc = "Corrected summary (100 word limit)")] + pub summary: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SummarizeConversationOutput { + pub summary: String, +} + +pub struct CompactionManager { + max_retries: usize, +} + +impl CompactionManager { + pub fn new() -> Self { + Self { max_retries: 2 } + } + + pub fn should_compact(&self, current_tokens: usize, max_tokens: usize, threshold: f32) -> bool { + current_tokens > ((max_tokens as f32 * threshold) as usize) + } + + pub async fn summarize( + &self, + lm: &Arc, + previous_summary: &str, + new_messages: &str, + ) -> Result { + let input = SummarizeConversationInput { + previous_summary: previous_summary.to_string(), + new_messages: new_messages.to_string(), + }; + + // First attempt + match call_summarize_conversation(lm, &input).await { + Ok(out) => return Ok(out.summary), + Err(SummarizationError::Parse { + raw_response, + error_message, + }) => { + if let Ok(corrected) = self + .try_correction( + lm, + previous_summary, + new_messages, + &raw_response, + &error_message, + ) + .await + { + return Ok(corrected); + } + } + Err(SummarizationError::Api(e)) => return Err(e), + }; + + for _attempt in 1..=self.max_retries { + match call_summarize_conversation(lm, &input).await { + Ok(out) => return Ok(out.summary), + Err(SummarizationError::Parse { + raw_response, + error_message, + }) => { + if let Ok(corrected) = self + .try_correction( + lm, + previous_summary, + new_messages, + &raw_response, + &error_message, + ) + .await + { + return Ok(corrected); + } + } + Err(SummarizationError::Api(e)) => { + return Err(e); + } + }; + } + + Err(ApiError::InternalServerError) + } + + async fn try_correction( + &self, + lm: &Arc, + previous_summary: &str, + new_messages: &str, + raw_response: &str, + error_message: &str, + ) -> Result { + let input = SummarizationCorrectionInput { + previous_summary: previous_summary.to_string(), + new_messages: new_messages.to_string(), + malformed_response: raw_response.to_string(), + error_message: error_message.to_string(), + }; + + let out = call_summarization_correction(lm, &input).await?; + Ok(out.summary) + } +} + +impl Default for CompactionManager { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug)] +enum SummarizationError { + Api(ApiError), + Parse { + raw_response: String, + error_message: String, + }, +} + +async fn call_summarize_conversation( + lm: &Arc, + input: &SummarizeConversationInput, +) -> Result { + let adapter = ChatAdapter; + + let system = adapter + .format_system_message_typed_with_instruction::(Some( + SUMMARY_INSTRUCTION, + )) + .map_err(|e| { + error!("Failed to format DSRS system prompt: {e:?}"); + SummarizationError::Api(ApiError::InternalServerError) + })?; + let user_msg = adapter.format_user_message_typed::(input); + + let mut chat = dspy_rs::Chat::new(vec![]); + chat.push("system", &system); + chat.push("user", &user_msg); + + let response = lm.call(chat, Vec::new()).await.map_err(|e| { + error!("DSRS LM call failed: {e:?}"); + SummarizationError::Api(ApiError::InternalServerError) + })?; + + let raw_response = response.output.content(); + let (output, _meta) = adapter + .parse_response_typed::(&response.output) + .map_err(|e| { + error!("DSRS typed parse failed: {e:?}"); + SummarizationError::Parse { + raw_response, + error_message: e.to_string(), + } + })?; + + Ok(SummarizeConversationOutput { + summary: output.summary, + }) +} + +async fn call_summarization_correction( + lm: &Arc, + input: &SummarizationCorrectionInput, +) -> Result { + let adapter = ChatAdapter; + + let system = adapter + .format_system_message_typed_with_instruction::(Some( + SUMMARY_CORRECTION_INSTRUCTION, + )) + .map_err(|e| { + error!("Failed to format DSRS system prompt: {e:?}"); + ApiError::InternalServerError + })?; + let user_msg = adapter.format_user_message_typed::(input); + + let mut chat = dspy_rs::Chat::new(vec![]); + chat.push("system", &system); + chat.push("user", &user_msg); + + let response = lm.call(chat, Vec::new()).await.map_err(|e| { + error!("DSRS LM call failed: {e:?}"); + ApiError::InternalServerError + })?; + + let (output, _meta) = adapter + .parse_response_typed::(&response.output) + .map_err(|e| { + error!("DSRS typed parse failed: {e:?}"); + ApiError::InternalServerError + })?; + + Ok(SummarizeConversationOutput { + summary: output.summary, + }) +} diff --git a/src/web/agent/mod.rs b/src/web/agent/mod.rs new file mode 100644 index 00000000..bf55e916 --- /dev/null +++ b/src/web/agent/mod.rs @@ -0,0 +1,1461 @@ +use axum::{ + extract::{Path, Query, State}, + http::{header, HeaderName, HeaderValue}, + middleware::from_fn_with_state, + response::{ + sse::{Event, KeepAlive, Sse}, + IntoResponse, Response, + }, + routing::{delete, get, post}, + Extension, Json, Router, +}; +use futures::Stream; +use secp256k1::SecretKey; +use serde::{Deserialize, Serialize}; +use std::{convert::Infallible, sync::Arc, time::Duration}; +use tokio::sync::{mpsc, oneshot}; +use tracing::{debug, error, warn}; +use uuid::Uuid; + +use crate::encrypt::decrypt_string; +use crate::models::agents::{ + Agent, AGENT_CREATED_BY_AGENT, AGENT_CREATED_BY_USER, AGENT_KIND_SUBAGENT, +}; +use crate::models::memory_blocks::MemoryBlock; +use crate::models::responses::{AssistantMessage, Conversation, ResponsesError}; +use crate::models::users::User; +use crate::push::{enqueue_agent_message_notification, AgentPushTarget}; +use crate::web::encryption_middleware::{decrypt_request, encrypt_response, EncryptedResponse}; +use crate::web::responses::constants::{ + DEFAULT_PAGINATION_LIMIT, DEFAULT_PAGINATION_ORDER, MAX_PAGINATION_LIMIT, OBJECT_TYPE_LIST, +}; +use crate::web::responses::conversations::{ConversationItemListResponse, ListItemsParams}; +use crate::web::responses::handlers::encrypt_event; +use crate::web::responses::{ + error_mapping, ConversationItem, ConversationItemConverter, DeletedObjectResponse, + MessageContent, MessageContentConverter, MessageContentPart, Paginator, +}; +use crate::{ApiError, AppMode, AppState}; + +mod compaction; +mod reactions; +mod runtime; +mod schedules; +mod signatures; +mod tools; +mod vision; + +pub(crate) use schedules::start_schedule_worker; + +#[derive(Debug, Clone, Deserialize)] +struct AgentChatRequest { + input: MessageContent, +} + +#[derive(Debug, Clone, Deserialize)] +struct SetMessageReactionRequest { + emoji: String, +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct InitMainAgentRequest { + timezone: Option, + locale: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct CreateSubagentRequest { + display_name: Option, + purpose: String, +} + +#[derive(Debug, Clone, Serialize)] +struct SubagentResponse { + id: Uuid, + object: &'static str, + kind: &'static str, + conversation_id: Uuid, + display_name: String, + purpose: String, + created_by: String, + created_at: i64, + updated_at: i64, +} + +#[derive(Debug, Clone, Serialize)] +struct MainAgentResponse { + id: Uuid, + object: &'static str, + kind: &'static str, + conversation_id: Uuid, + display_name: &'static str, + created_at: i64, + updated_at: i64, +} + +#[derive(Debug, Clone, Serialize)] +struct InitMainAgentResponse { + #[serde(flatten)] + agent: MainAgentResponse, + messages: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct SubagentListResponse { + object: &'static str, + data: Vec, + has_more: bool, + first_id: Option, + last_id: Option, +} + +#[derive(Debug, Deserialize)] +struct ListSubagentsParams { + #[serde(default = "default_limit")] + limit: i64, + after: Option, + #[serde(default = "default_order")] + order: String, + created_by: Option, +} + +struct AgentConversationContext { + agent: Agent, + conversation: Conversation, + user_key: SecretKey, +} + +#[derive(Debug, Clone)] +enum ChatTarget { + Main, + Subagent(Uuid), +} + +fn chat_target_label(target: &ChatTarget) -> &'static str { + match target { + ChatTarget::Main => "main", + ChatTarget::Subagent(_) => "subagent", + } +} + +const MAIN_AGENT_DISPLAY_NAME: &str = "Maple"; + +fn default_limit() -> i64 { + DEFAULT_PAGINATION_LIMIT +} + +fn default_order() -> String { + DEFAULT_PAGINATION_ORDER.to_string() +} + +fn is_empty_message_content(content: &MessageContent) -> bool { + match content { + MessageContent::Text(text) => text.trim().is_empty(), + MessageContent::Parts(parts) => parts.iter().all(|part| match part { + MessageContentPart::Text { text } | MessageContentPart::InputText { text } => { + text.trim().is_empty() + } + MessageContentPart::InputImage { + image_url, file_id, .. + } => { + let url_empty = image_url + .as_deref() + .map(|s| s.trim().is_empty()) + .unwrap_or(true); + let file_empty = file_id + .as_deref() + .map(|s| s.trim().is_empty()) + .unwrap_or(true); + url_empty && file_empty + } + MessageContentPart::InputFile { + filename, + file_data, + } => filename.trim().is_empty() && file_data.trim().is_empty(), + }), + } +} + +// SSE event types for agent chat (message-level delivery, not token streaming) +const EVENT_AGENT_MESSAGE: &str = "agent.message"; +const EVENT_AGENT_REACTION: &str = "agent.reaction"; +const EVENT_AGENT_TYPING: &str = "agent.typing"; +const EVENT_AGENT_DONE: &str = "agent.done"; +const EVENT_AGENT_ERROR: &str = "agent.error"; +const AGENT_SSE_KEEPALIVE_INTERVAL_SECS: u64 = 15; + +#[derive(Debug, Clone, Serialize)] +struct AgentMessageEvent { + message_id: Uuid, + message: String, +} + +#[derive(Debug, Clone, Serialize)] +struct AgentReactionEvent { + item_id: Uuid, + emoji: String, +} + +#[derive(Debug, Clone, Serialize)] +struct AgentTypingEvent {} + +#[derive(Debug, Clone, Serialize)] +struct AgentDoneEvent {} + +#[derive(Debug, Clone, Serialize)] +struct AgentErrorEvent { + error: String, +} + +#[derive(Debug)] +enum AgentClientEvent { + Typing(AgentTypingEvent), + Message { + payload: AgentMessageEvent, + delivery_ack: oneshot::Sender<()>, + }, + Reaction(AgentReactionEvent), + Done(AgentDoneEvent), + Error(AgentErrorEvent), +} + +async fn encrypt_agent_event( + state: &AppState, + session_id: &Uuid, + event_type: &str, + payload: &T, +) -> Result { + let value = serde_json::to_value(payload).map_err(|e| { + error!("Failed to serialize agent SSE payload for {event_type}: {e:?}"); + ApiError::InternalServerError + })?; + + encrypt_event(state, session_id, event_type, &value).await +} + +fn agent_sse_response(event_stream: S) -> Response +where + S: Stream> + Send + 'static, +{ + let sse = Sse::new(event_stream).keep_alive( + KeepAlive::new() + .interval(Duration::from_secs(AGENT_SSE_KEEPALIVE_INTERVAL_SECS)) + .text("keep-alive"), + ); + + ( + [ + (header::CACHE_CONTROL, HeaderValue::from_static("no-cache")), + ( + HeaderName::from_static("x-accel-buffering"), + HeaderValue::from_static("no"), + ), + ], + sse, + ) + .into_response() +} + +fn build_main_agent_response(ctx: &AgentConversationContext) -> MainAgentResponse { + MainAgentResponse { + id: ctx.agent.uuid, + object: "agent.main", + kind: "main", + conversation_id: ctx.conversation.uuid, + display_name: MAIN_AGENT_DISPLAY_NAME, + created_at: ctx.agent.created_at.timestamp(), + updated_at: ctx.conversation.updated_at.timestamp(), + } +} + +fn build_init_main_agent_response( + ctx: &AgentConversationContext, + onboarding_messages: Vec, +) -> InitMainAgentResponse { + InitMainAgentResponse { + agent: build_main_agent_response(ctx), + messages: onboarding_messages + .into_iter() + .map(|message| ConversationItem::Message { + id: message.id, + status: Some("completed".to_string()), + role: "assistant".to_string(), + content: MessageContentConverter::assistant_text_to_content(message.content), + reaction: None, + created_at: Some(message.created_at), + }) + .collect(), + } +} + +fn build_subagent_response( + agent: &Agent, + conversation: &Conversation, + user_key: &SecretKey, +) -> Result { + let display_name = decrypt_string(user_key, agent.display_name_enc.as_ref()) + .map_err(|e| { + error!("Failed to decrypt subagent display name: {e:?}"); + ApiError::InternalServerError + })? + .unwrap_or_else(|| "Subagent".to_string()); + + let purpose = decrypt_string(user_key, agent.purpose_enc.as_ref()) + .map_err(|e| { + error!("Failed to decrypt subagent purpose: {e:?}"); + ApiError::InternalServerError + })? + .unwrap_or_default(); + + Ok(SubagentResponse { + id: agent.uuid, + object: "agent.subagent", + kind: "subagent", + conversation_id: conversation.uuid, + display_name, + purpose, + created_by: agent.created_by.clone(), + created_at: agent.created_at.timestamp(), + updated_at: conversation.updated_at.timestamp(), + }) +} + +fn validate_created_by_filter(created_by: Option<&str>) -> Result, ApiError> { + match created_by { + None => Ok(None), + Some(AGENT_CREATED_BY_USER) => Ok(Some(AGENT_CREATED_BY_USER)), + Some(AGENT_CREATED_BY_AGENT) => Ok(Some(AGENT_CREATED_BY_AGENT)), + Some(_) => Err(ApiError::BadRequest), + } +} + +async fn load_main_agent_context( + state: &Arc, + user: &User, +) -> Result { + let user_key = state + .get_user_key(user.uuid, None, None) + .await + .map_err(|_| error_mapping::map_key_retrieval_error())?; + + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + let (agent, conversation) = + runtime::load_main_agent(&mut conn, user.uuid)?.ok_or(ApiError::NotFound)?; + + Ok(AgentConversationContext { + agent, + conversation, + user_key, + }) +} + +async fn load_subagent_context( + state: &Arc, + user: &User, + agent_uuid: Uuid, +) -> Result { + let user_key = state + .get_user_key(user.uuid, None, None) + .await + .map_err(|_| error_mapping::map_key_retrieval_error())?; + + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + let agent = Agent::get_by_uuid_and_user(&mut conn, agent_uuid, user.uuid) + .map_err(|e| { + error!("Failed to load subagent: {e:?}"); + ApiError::InternalServerError + })? + .ok_or(ApiError::NotFound)?; + + if agent.kind != AGENT_KIND_SUBAGENT { + return Err(ApiError::NotFound); + } + + let conversation = + Conversation::get_by_id_and_user(&mut conn, agent.conversation_id, user.uuid).map_err( + |e| { + error!("Failed to load subagent conversation: {e:?}"); + ApiError::InternalServerError + }, + )?; + + Ok(AgentConversationContext { + agent, + conversation, + user_key, + }) +} + +fn list_items_for_conversation( + state: &Arc, + conversation_id: i64, + user_key: &SecretKey, + params: &ListItemsParams, +) -> Result { + let limit = if params.limit <= 0 { + DEFAULT_PAGINATION_LIMIT + } else { + params.limit.min(MAX_PAGINATION_LIMIT) + }; + + let raw_messages = state + .db + .get_conversation_context_messages(conversation_id, limit + 1, params.after, ¶ms.order) + .map_err(error_mapping::map_message_error)?; + + let has_more = raw_messages.len() > limit as usize; + let messages_to_return = if has_more { + &raw_messages[..limit as usize] + } else { + &raw_messages[..] + }; + + let items = ConversationItemConverter::messages_to_items( + messages_to_return, + user_key, + 0, + messages_to_return.len(), + )?; + + let (first_id, last_id) = Paginator::get_cursor_ids(&items, |item| match item { + ConversationItem::Message { id, .. } => *id, + ConversationItem::FunctionToolCall { id, .. } => *id, + ConversationItem::FunctionToolCallOutput { id, .. } => *id, + ConversationItem::Reasoning { id, .. } => *id, + }); + + Ok(ConversationItemListResponse { + object: OBJECT_TYPE_LIST, + data: items, + has_more, + first_id, + last_id, + }) +} + +fn get_item_from_conversation( + state: &Arc, + conversation_id: i64, + user_key: &SecretKey, + item_id: Uuid, +) -> Result { + let raw_messages = state + .db + .get_conversation_context_messages(conversation_id, i64::MAX, None, "asc") + .map_err(error_mapping::map_message_error)?; + + for msg in raw_messages { + if msg.uuid == item_id { + return ConversationItemConverter::message_to_item(&msg, user_key); + } + } + + Err(ApiError::NotFound) +} + +fn set_user_reaction_for_conversation( + state: &Arc, + conversation_id: i64, + user_key: &SecretKey, + user: &User, + item_id: Uuid, + reaction: Option, +) -> Result { + let item = get_item_from_conversation(state, conversation_id, user_key, item_id)?; + + match item { + ConversationItem::Message { role, .. } if role == "assistant" => {} + _ => return Err(ApiError::BadRequest), + } + + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + AssistantMessage::set_user_reaction(&mut conn, item_id, user.uuid, reaction).map_err(|e| { + error!("Failed to set user reaction on assistant message: {e:?}"); + match e { + ResponsesError::AssistantMessageNotFound => ApiError::NotFound, + _ => ApiError::InternalServerError, + } + })?; + + get_item_from_conversation(state, conversation_id, user_key, item_id) +} + +fn delete_conversation_for_user( + conn: &mut diesel::PgConnection, + conversation_id: i64, + user_id: Uuid, +) -> Result<(), ApiError> { + Conversation::delete_by_id_and_user(conn, conversation_id, user_id).map_err(|e| { + error!("Failed to delete agent conversation: {e:?}"); + ApiError::InternalServerError + }) +} + +pub fn router(app_state: Arc) -> Router<()> { + // Experimental endpoints: only enabled in Local/Dev. + if !matches!(app_state.app_mode, AppMode::Local | AppMode::Dev) { + return Router::new().with_state(app_state); + } + + Router::new() + .route( + "/v1/agent", + get(get_main_agent).layer(from_fn_with_state(app_state.clone(), decrypt_request::<()>)), + ) + .route( + "/v1/agent", + delete(delete_main_agent) + .layer(from_fn_with_state(app_state.clone(), decrypt_request::<()>)), + ) + .route( + "/v1/agent/init", + post(init_main_agent).layer(from_fn_with_state( + app_state.clone(), + decrypt_request::, + )), + ) + .route( + "/v1/agent/items", + get(list_main_agent_items) + .layer(from_fn_with_state(app_state.clone(), decrypt_request::<()>)), + ) + .route( + "/v1/agent/items/:item_id", + get(get_main_agent_item) + .layer(from_fn_with_state(app_state.clone(), decrypt_request::<()>)), + ) + .route( + "/v1/agent/items/:item_id/reaction", + post(set_main_agent_item_reaction) + .delete(clear_main_agent_item_reaction) + .layer(from_fn_with_state( + app_state.clone(), + decrypt_request::, + )), + ) + .route( + "/v1/agent/chat", + post(chat_main).layer(from_fn_with_state( + app_state.clone(), + decrypt_request::, + )), + ) + .route( + "/v1/agent/subagents", + get(list_subagents).layer(from_fn_with_state(app_state.clone(), decrypt_request::<()>)), + ) + .route( + "/v1/agent/subagents", + post(create_subagent).layer(from_fn_with_state( + app_state.clone(), + decrypt_request::, + )), + ) + .route( + "/v1/agent/subagents/:id", + get(get_subagent).layer(from_fn_with_state(app_state.clone(), decrypt_request::<()>)), + ) + .route( + "/v1/agent/subagents/:id/chat", + post(chat_subagent).layer(from_fn_with_state( + app_state.clone(), + decrypt_request::, + )), + ) + .route( + "/v1/agent/subagents/:id/items", + get(list_subagent_items) + .layer(from_fn_with_state(app_state.clone(), decrypt_request::<()>)), + ) + .route( + "/v1/agent/subagents/:id/items/:item_id", + get(get_subagent_item) + .layer(from_fn_with_state(app_state.clone(), decrypt_request::<()>)), + ) + .route( + "/v1/agent/subagents/:id/items/:item_id/reaction", + post(set_subagent_item_reaction) + .delete(clear_subagent_item_reaction) + .layer(from_fn_with_state( + app_state.clone(), + decrypt_request::, + )), + ) + .route( + "/v1/agent/subagents/:id", + delete(delete_subagent) + .layer(from_fn_with_state(app_state.clone(), decrypt_request::<()>)), + ) + .with_state(app_state) +} + +async fn get_main_agent( + State(state): State>, + Extension(session_id): Extension, + Extension(user): Extension, +) -> Result>, ApiError> { + let ctx = load_main_agent_context(&state, &user).await?; + let response = build_main_agent_response(&ctx); + + encrypt_response(&state, &session_id, &response).await +} + +async fn init_main_agent( + State(state): State>, + Extension(session_id): Extension, + Extension(user): Extension, + Extension(body): Extension, +) -> Result>, ApiError> { + let user_key = state + .get_user_key(user.uuid, None, None) + .await + .map_err(|_| error_mapping::map_key_retrieval_error())?; + + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + let init_result = runtime::init_main_agent( + &state, + &mut conn, + &user_key, + user.uuid, + &runtime::MainAgentInitOptions { + timezone: body.timezone, + locale: body.locale, + }, + ) + .await?; + + let response = build_init_main_agent_response( + &AgentConversationContext { + agent: init_result.agent, + conversation: init_result.conversation, + user_key, + }, + init_result.onboarding_messages, + ); + + encrypt_response(&state, &session_id, &response).await +} + +async fn list_main_agent_items( + State(state): State>, + Query(params): Query, + Extension(session_id): Extension, + Extension(user): Extension, +) -> Result>, ApiError> { + let ctx = load_main_agent_context(&state, &user).await?; + let response = + list_items_for_conversation(&state, ctx.conversation.id, &ctx.user_key, ¶ms)?; + + encrypt_response(&state, &session_id, &response).await +} + +async fn get_main_agent_item( + State(state): State>, + Path(item_id): Path, + Extension(session_id): Extension, + Extension(user): Extension, +) -> Result>, ApiError> { + let ctx = load_main_agent_context(&state, &user).await?; + let item = get_item_from_conversation(&state, ctx.conversation.id, &ctx.user_key, item_id)?; + + encrypt_response(&state, &session_id, &item).await +} + +async fn set_main_agent_item_reaction( + State(state): State>, + Path(item_id): Path, + Extension(session_id): Extension, + Extension(user): Extension, + Extension(body): Extension, +) -> Result>, ApiError> { + let emoji = reactions::require_valid_reaction(&body.emoji)?; + let ctx = load_main_agent_context(&state, &user).await?; + let item = set_user_reaction_for_conversation( + &state, + ctx.conversation.id, + &ctx.user_key, + &user, + item_id, + Some(emoji), + )?; + + encrypt_response(&state, &session_id, &item).await +} + +async fn clear_main_agent_item_reaction( + State(state): State>, + Path(item_id): Path, + Extension(session_id): Extension, + Extension(user): Extension, +) -> Result>, ApiError> { + let ctx = load_main_agent_context(&state, &user).await?; + let item = set_user_reaction_for_conversation( + &state, + ctx.conversation.id, + &ctx.user_key, + &user, + item_id, + None, + )?; + + encrypt_response(&state, &session_id, &item).await +} + +async fn delete_main_agent( + State(state): State>, + Extension(session_id): Extension, + Extension(user): Extension, +) -> Result>, ApiError> { + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + let main_agent = Agent::get_main_for_user(&mut conn, user.uuid) + .map_err(|e| { + error!("Failed to load main agent for deletion: {e:?}"); + ApiError::InternalServerError + })? + .ok_or(ApiError::NotFound)?; + + let subagents = Agent::list_subagents_for_user(&mut conn, user.uuid).map_err(|e| { + error!("Failed to load subagents for main agent deletion: {e:?}"); + ApiError::InternalServerError + })?; + + for subagent in subagents { + delete_conversation_for_user(&mut conn, subagent.conversation_id, user.uuid)?; + } + + delete_conversation_for_user(&mut conn, main_agent.conversation_id, user.uuid)?; + + MemoryBlock::delete_all_for_user(&mut conn, user.uuid).map_err(|e| { + error!("Failed to clear agent memory blocks during main agent deletion: {e:?}"); + ApiError::InternalServerError + })?; + + state.rag_cache.lock().await.evict_user(user.uuid); + + let response = DeletedObjectResponse { + id: main_agent.uuid, + object: "agent.main.deleted", + deleted: true, + }; + + encrypt_response(&state, &session_id, &response).await +} + +async fn list_subagents( + State(state): State>, + Query(params): Query, + Extension(session_id): Extension, + Extension(user): Extension, +) -> Result>, ApiError> { + let limit = if params.limit <= 0 { + DEFAULT_PAGINATION_LIMIT + } else { + params.limit.min(MAX_PAGINATION_LIMIT) + }; + + let created_by_filter = validate_created_by_filter(params.created_by.as_deref())?; + + let user_key = state + .get_user_key(user.uuid, None, None) + .await + .map_err(|_| error_mapping::map_key_retrieval_error())?; + + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + let subagents = Agent::list_subagents_for_user_paginated( + &mut conn, + user.uuid, + limit + 1, + params.after, + ¶ms.order, + created_by_filter, + ) + .map_err(|e| { + error!("Failed to list subagents: {e:?}"); + ApiError::InternalServerError + })?; + + let has_more = subagents.len() > limit as usize; + let subagents_to_return = if has_more { + &subagents[..limit as usize] + } else { + &subagents[..] + }; + + let data = subagents_to_return + .iter() + .map(|(agent, conversation)| build_subagent_response(agent, conversation, &user_key)) + .collect::, _>>()?; + + let (first_id, last_id) = + Paginator::get_cursor_ids(subagents_to_return, |(agent, _)| agent.uuid); + + let response = SubagentListResponse { + object: OBJECT_TYPE_LIST, + data, + has_more, + first_id, + last_id, + }; + + encrypt_response(&state, &session_id, &response).await +} + +async fn get_subagent( + State(state): State>, + Path(agent_uuid): Path, + Extension(session_id): Extension, + Extension(user): Extension, +) -> Result>, ApiError> { + let ctx = load_subagent_context(&state, &user, agent_uuid).await?; + let response = build_subagent_response(&ctx.agent, &ctx.conversation, &ctx.user_key)?; + + encrypt_response(&state, &session_id, &response).await +} + +async fn list_subagent_items( + State(state): State>, + Path(agent_uuid): Path, + Query(params): Query, + Extension(session_id): Extension, + Extension(user): Extension, +) -> Result>, ApiError> { + let ctx = load_subagent_context(&state, &user, agent_uuid).await?; + let response = + list_items_for_conversation(&state, ctx.conversation.id, &ctx.user_key, ¶ms)?; + + encrypt_response(&state, &session_id, &response).await +} + +async fn get_subagent_item( + State(state): State>, + Path((agent_uuid, item_id)): Path<(Uuid, Uuid)>, + Extension(session_id): Extension, + Extension(user): Extension, +) -> Result>, ApiError> { + let ctx = load_subagent_context(&state, &user, agent_uuid).await?; + let item = get_item_from_conversation(&state, ctx.conversation.id, &ctx.user_key, item_id)?; + + encrypt_response(&state, &session_id, &item).await +} + +async fn set_subagent_item_reaction( + State(state): State>, + Path((agent_uuid, item_id)): Path<(Uuid, Uuid)>, + Extension(session_id): Extension, + Extension(user): Extension, + Extension(body): Extension, +) -> Result>, ApiError> { + let emoji = reactions::require_valid_reaction(&body.emoji)?; + let ctx = load_subagent_context(&state, &user, agent_uuid).await?; + let item = set_user_reaction_for_conversation( + &state, + ctx.conversation.id, + &ctx.user_key, + &user, + item_id, + Some(emoji), + )?; + + encrypt_response(&state, &session_id, &item).await +} + +async fn clear_subagent_item_reaction( + State(state): State>, + Path((agent_uuid, item_id)): Path<(Uuid, Uuid)>, + Extension(session_id): Extension, + Extension(user): Extension, +) -> Result>, ApiError> { + let ctx = load_subagent_context(&state, &user, agent_uuid).await?; + let item = set_user_reaction_for_conversation( + &state, + ctx.conversation.id, + &ctx.user_key, + &user, + item_id, + None, + )?; + + encrypt_response(&state, &session_id, &item).await +} + +async fn chat_main( + State(state): State>, + Extension(session_id): Extension, + Extension(user): Extension, + Extension(body): Extension, +) -> Result { + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + if runtime::load_main_agent(&mut conn, user.uuid)?.is_none() { + return Err(ApiError::NotFound); + } + + chat_with_target(state, session_id, user, body, ChatTarget::Main).await +} + +async fn chat_subagent( + State(state): State>, + Path(agent_uuid): Path, + Extension(session_id): Extension, + Extension(user): Extension, + Extension(body): Extension, +) -> Result { + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + let agent = Agent::get_by_uuid_and_user(&mut conn, agent_uuid, user.uuid) + .map_err(|_| ApiError::InternalServerError)? + .ok_or(ApiError::NotFound)?; + + if agent.kind != AGENT_KIND_SUBAGENT { + return Err(ApiError::NotFound); + } + + chat_with_target( + state, + session_id, + user, + body, + ChatTarget::Subagent(agent_uuid), + ) + .await +} + +async fn chat_with_target( + state: Arc, + session_id: Uuid, + user: User, + body: AgentChatRequest, + target: ChatTarget, +) -> Result { + MessageContentConverter::validate_content(&body.input)?; + let input_content = MessageContentConverter::normalize_content(body.input.clone()); + if is_empty_message_content(&input_content) { + return Err(ApiError::BadRequest); + } + + let (tx, mut rx) = mpsc::channel::(32); + let worker_state = state.clone(); + let worker_user = user.clone(); + let worker_target = target.clone(); + + tokio::spawn(async move { + run_agent_chat_task(worker_state, worker_user, input_content, worker_target, tx).await; + }); + + let event_stream = async_stream::stream! { + while let Some(client_event) = rx.recv().await { + let encrypted = match client_event { + AgentClientEvent::Typing(payload) => { + encrypt_agent_event(&state, &session_id, EVENT_AGENT_TYPING, &payload).await + } + AgentClientEvent::Message { payload, delivery_ack } => { + let encrypted = encrypt_agent_event( + &state, + &session_id, + EVENT_AGENT_MESSAGE, + &payload, + ) + .await; + + match encrypted { + Ok(event) => { + yield Ok(event); + let _ = delivery_ack.send(()); + continue; + } + Err(error) => Err(error), + } + } + AgentClientEvent::Reaction(payload) => { + encrypt_agent_event(&state, &session_id, EVENT_AGENT_REACTION, &payload).await + } + AgentClientEvent::Done(payload) => { + encrypt_agent_event(&state, &session_id, EVENT_AGENT_DONE, &payload).await + } + AgentClientEvent::Error(payload) => { + encrypt_agent_event(&state, &session_id, EVENT_AGENT_ERROR, &payload).await + } + }; + + match encrypted { + Ok(event) => yield Ok(event), + Err(e) => { + error!("Failed to encrypt agent SSE event: {e:?}"); + yield Ok(Event::default().event(EVENT_AGENT_ERROR).data("Encryption failed")); + break; + } + } + } + }; + + Ok(agent_sse_response(event_stream)) +} + +async fn run_agent_chat_task( + state: Arc, + user: User, + input_content: MessageContent, + target: ChatTarget, + tx: mpsc::Sender, +) { + let (input_kind, input_part_count) = match &input_content { + MessageContent::Text(_) => ("text", 1), + MessageContent::Parts(parts) => ("parts", parts.len()), + }; + debug!( + user_uuid = %user.uuid, + target = chat_target_label(&target), + input_kind, + input_part_count, + "Starting agent chat task" + ); + let mut total_messages: usize = 0; + let mut had_reaction = false; + let mut had_error = false; + let mut client_connected = true; + let mut first_undelivered_message: Option<(Uuid, String)> = None; + + client_connected = send_agent_client_event( + &tx, + AgentClientEvent::Typing(AgentTypingEvent {}), + client_connected, + ) + .await; + + let user_key = match state.get_user_key(user.uuid, None, None).await { + Ok(key) => key, + Err(_) => { + let _ = send_agent_client_event( + &tx, + AgentClientEvent::Error(AgentErrorEvent { + error: "Failed to initialize agent session.".to_string(), + }), + client_connected, + ) + .await; + return; + } + }; + + let runtime = match target.clone() { + ChatTarget::Main => { + runtime::AgentRuntime::new_main(state.clone(), user.clone(), user_key).await + } + ChatTarget::Subagent(agent_uuid) => { + runtime::AgentRuntime::new_subagent(state.clone(), user.clone(), user_key, agent_uuid) + .await + } + }; + + let mut runtime = match runtime { + Ok(runtime) => runtime, + Err(e) => { + error!("Agent runtime initialization error: {e:?}"); + let error_message = if matches!(e, ApiError::NotFound) { + "Main agent is not initialized.".to_string() + } else { + "Failed to initialize agent runtime.".to_string() + }; + let _ = send_agent_client_event( + &tx, + AgentClientEvent::Error(AgentErrorEvent { + error: error_message, + }), + client_connected, + ) + .await; + return; + } + }; + + let input_for_agent = match runtime.prepare(&input_content).await { + Ok(prepared) => prepared, + Err(e) => { + error!("Agent prepare() failed: {e:?}"); + let _ = send_agent_client_event( + &tx, + AgentClientEvent::Error(AgentErrorEvent { + error: "Agent encountered an error preparing your request.".to_string(), + }), + client_connected, + ) + .await; + return; + } + }; + + let max_steps = runtime.max_steps(); + + 'steps: for step_num in 0..max_steps { + debug!( + user_uuid = %user.uuid, + target = chat_target_label(&target), + step_num, + total_messages, + had_reaction, + "Starting agent chat step" + ); + + match runtime.step(&input_for_agent, step_num == 0).await { + Ok(result) => { + let runtime::StepResult { + messages, + reply_reaction, + executed_tools, + done, + .. + } = result; + + debug!( + user_uuid = %user.uuid, + target = chat_target_label(&target), + step_num, + message_count = messages.len(), + executed_tool_count = executed_tools.len(), + tool_names = ?executed_tools + .iter() + .map(|executed| executed.tool_call.name.as_str()) + .collect::>(), + has_reply_reaction = reply_reaction.is_some(), + done, + "Agent chat step returned" + ); + + for executed in &executed_tools { + if let Err(e) = runtime + .insert_tool_call_and_output(&executed.tool_call, &executed.result) + .await + { + error!("Failed to persist tool call: {e:?}"); + } + } + + if let Some(reaction) = reply_reaction { + match runtime + .set_assistant_reaction_for_current_user_message(&reaction) + .await + { + Ok(updated_message) => { + had_reaction = true; + client_connected = send_agent_client_event( + &tx, + AgentClientEvent::Reaction(AgentReactionEvent { + item_id: updated_message.uuid, + emoji: reaction, + }), + client_connected, + ) + .await; + } + Err(e) => { + error!("Failed to persist assistant reaction: {e:?}"); + } + } + } + + if !messages.is_empty() { + for msg in messages { + let assistant_message = match runtime.insert_assistant_message(&msg).await { + Ok(message) => message, + Err(e) => { + error!("Failed to persist assistant message: {e:?}"); + had_error = true; + let _ = send_agent_client_event( + &tx, + AgentClientEvent::Error(AgentErrorEvent { + error: "Agent encountered an error saving its response." + .to_string(), + }), + client_connected, + ) + .await; + break 'steps; + } + }; + + total_messages += 1; + + let delivered = send_agent_message_event( + &tx, + AgentMessageEvent { + message_id: assistant_message.uuid, + message: msg.clone(), + }, + client_connected, + ) + .await; + + if !delivered { + client_connected = false; + if first_undelivered_message.is_none() { + first_undelivered_message = + Some((assistant_message.uuid, msg.clone())); + } + } else { + client_connected = true; + } + } + } + + if done { + break 'steps; + } + } + Err(e) => { + error!("Agent step {} error: {e:?}", step_num); + had_error = true; + let _ = send_agent_client_event( + &tx, + AgentClientEvent::Error(AgentErrorEvent { + error: "Agent encountered an error processing your message.".to_string(), + }), + client_connected, + ) + .await; + break 'steps; + } + } + } + + if !had_error && total_messages == 0 && !had_reaction { + warn!( + user_uuid = %user.uuid, + target = chat_target_label(&target), + "Agent produced no messages or reactions; sending fallback response" + ); + let fallback_message = "I apologize, but I wasn't able to generate a response.".to_string(); + match runtime.insert_assistant_message(&fallback_message).await { + Ok(message) => { + total_messages = 1; + let delivered = send_agent_message_event( + &tx, + AgentMessageEvent { + message_id: message.uuid, + message: fallback_message.clone(), + }, + client_connected, + ) + .await; + + if !delivered { + client_connected = false; + if first_undelivered_message.is_none() { + first_undelivered_message = Some((message.uuid, fallback_message)); + } + } else { + client_connected = true; + } + } + Err(e) => { + error!("Failed to persist fallback assistant message: {e:?}"); + had_error = true; + let _ = send_agent_client_event( + &tx, + AgentClientEvent::Error(AgentErrorEvent { + error: "Agent encountered an error saving its response.".to_string(), + }), + client_connected, + ) + .await; + } + } + } + + if let Some((message_id, message_text)) = first_undelivered_message { + enqueue_agent_push_for_disconnect(&state, &user, &target, message_id, &message_text).await; + } + + if !had_error { + let _ = send_agent_client_event( + &tx, + AgentClientEvent::Done(AgentDoneEvent {}), + client_connected, + ) + .await; + } + + debug!( + user_uuid = %user.uuid, + target = chat_target_label(&target), + total_messages, + had_reaction, + had_error, + client_connected, + "Agent chat task finished" + ); +} + +async fn send_agent_client_event( + tx: &mpsc::Sender, + event: AgentClientEvent, + client_connected: bool, +) -> bool { + if !client_connected { + return false; + } + + tx.send(event).await.is_ok() +} + +async fn send_agent_message_event( + tx: &mpsc::Sender, + payload: AgentMessageEvent, + client_connected: bool, +) -> bool { + if !client_connected { + return false; + } + + let (delivery_ack, ack_rx) = oneshot::channel(); + if tx + .send(AgentClientEvent::Message { + payload, + delivery_ack, + }) + .await + .is_err() + { + return false; + } + + ack_rx.await.is_ok() +} + +async fn enqueue_agent_push_for_disconnect( + state: &Arc, + user: &User, + target: &ChatTarget, + message_id: Uuid, + message_text: &str, +) { + let push_target = match target { + ChatTarget::Main => AgentPushTarget::Main, + ChatTarget::Subagent(agent_uuid) => AgentPushTarget::Subagent(*agent_uuid), + }; + + if let Err(e) = + enqueue_agent_message_notification(state, user, push_target, message_id, message_text).await + { + error!("Failed to enqueue agent push notification after SSE disconnect: {e:?}"); + } +} + +async fn create_subagent( + State(state): State>, + Extension(session_id): Extension, + Extension(user): Extension, + Extension(body): Extension, +) -> Result>, ApiError> { + if body.purpose.trim().is_empty() { + return Err(ApiError::BadRequest); + } + + let user_key = state + .get_user_key(user.uuid, None, None) + .await + .map_err(|_| error_mapping::map_key_retrieval_error())?; + + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + let (main_agent, _) = + runtime::load_main_agent(&mut conn, user.uuid)?.ok_or(ApiError::NotFound)?; + let (agent, conversation, display_name) = runtime::create_subagent( + &mut conn, + &user_key, + user.uuid, + &main_agent, + body.display_name.as_deref(), + body.purpose.trim(), + AGENT_CREATED_BY_USER, + ) + .await?; + + let response = SubagentResponse { + id: agent.uuid, + object: "agent.subagent", + kind: "subagent", + conversation_id: conversation.uuid, + display_name, + purpose: body.purpose.trim().to_string(), + created_by: agent.created_by, + created_at: agent.created_at.timestamp(), + updated_at: conversation.updated_at.timestamp(), + }; + + encrypt_response(&state, &session_id, &response).await +} + +async fn delete_subagent( + State(state): State>, + Path(agent_uuid): Path, + Extension(session_id): Extension, + Extension(user): Extension, +) -> Result>, ApiError> { + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + let agent = Agent::get_by_uuid_and_user(&mut conn, agent_uuid, user.uuid) + .map_err(|_| ApiError::InternalServerError)? + .ok_or(ApiError::NotFound)?; + + if agent.kind != AGENT_KIND_SUBAGENT { + return Err(ApiError::NotFound); + } + + delete_conversation_for_user(&mut conn, agent.conversation_id, user.uuid)?; + + state.rag_cache.lock().await.evict_user(user.uuid); + + let response = DeletedObjectResponse { + id: agent.uuid, + object: "agent.subagent.deleted", + deleted: true, + }; + + encrypt_response(&state, &session_id, &response).await +} diff --git a/src/web/agent/reactions.rs b/src/web/agent/reactions.rs new file mode 100644 index 00000000..dd9a6fba --- /dev/null +++ b/src/web/agent/reactions.rs @@ -0,0 +1,218 @@ +use crate::ApiError; +use serde_json::from_str; +use unicode_segmentation::UnicodeSegmentation; + +const MAX_REACTION_CHARS: usize = 16; + +fn canonicalize_optional_reaction(value: Option<&str>) -> Option { + let mut current = value.map(str::trim)?.to_string(); + if current.is_empty() { + return None; + } + + for _ in 0..2 { + let trimmed = current.trim(); + if !(trimmed.starts_with('"') && trimmed.ends_with('"')) { + break; + } + + let Ok(decoded) = from_str::(trimmed) else { + break; + }; + + let decoded = decoded.trim(); + if decoded.is_empty() { + return None; + } + + current = decoded.to_string(); + } + + Some(current.trim().to_string()) +} + +pub(super) fn has_meaningful_reaction_candidate(value: &str) -> bool { + canonicalize_optional_reaction(Some(value)).is_some() +} + +pub(super) fn normalize_optional_reaction(value: Option<&str>) -> Option { + let trimmed = canonicalize_optional_reaction(value)?; + + if trimmed.chars().count() > MAX_REACTION_CHARS { + return None; + } + + if trimmed.graphemes(true).count() != 1 { + return None; + } + + if !trimmed.chars().all(is_allowed_reaction_char) { + return None; + } + + if !has_visible_emoji_base(&trimmed) { + return None; + } + + Some(trimmed.to_string()) +} + +pub(super) fn require_valid_reaction(value: &str) -> Result { + normalize_optional_reaction(Some(value)).ok_or(ApiError::BadRequest) +} + +fn is_allowed_reaction_char(ch: char) -> bool { + matches!( + ch, + '#' | '*' | '0'..='9' + | '\u{00A9}' + | '\u{00AE}' + | '\u{200D}' + | '\u{203C}' + | '\u{2049}' + | '\u{20E3}' + | '\u{2122}' + | '\u{2139}' + | '\u{2194}'..='\u{2199}' + | '\u{21A9}'..='\u{21AA}' + | '\u{231A}'..='\u{231B}' + | '\u{2328}' + | '\u{23CF}' + | '\u{23E9}'..='\u{23F3}' + | '\u{23F8}'..='\u{23FA}' + | '\u{24C2}' + | '\u{25AA}'..='\u{25AB}' + | '\u{25B6}' + | '\u{25C0}' + | '\u{25FB}'..='\u{25FE}' + | '\u{2600}'..='\u{27BF}' + | '\u{2934}'..='\u{2935}' + | '\u{2B05}'..='\u{2B07}' + | '\u{2B1B}'..='\u{2B1C}' + | '\u{2B50}' + | '\u{2B55}' + | '\u{3030}' + | '\u{303D}' + | '\u{3297}' + | '\u{3299}' + | '\u{FE0E}' + | '\u{FE0F}' + | '\u{1F000}'..='\u{1FAFF}' + | '\u{E0020}'..='\u{E007F}' + ) +} + +fn has_visible_emoji_base(value: &str) -> bool { + has_keycap_base(value) || value.chars().any(is_non_component_emoji_char) +} + +fn has_keycap_base(value: &str) -> bool { + value.chars().any(|ch| matches!(ch, '#' | '*' | '0'..='9')) + && value.chars().any(|ch| ch == '\u{20E3}') +} + +fn is_non_component_emoji_char(ch: char) -> bool { + is_allowed_reaction_char(ch) && !is_component_only_reaction_char(ch) +} + +fn is_component_only_reaction_char(ch: char) -> bool { + matches!( + ch, + '#' | '*' | '0'..='9' + | '\u{200D}' + | '\u{20E3}' + | '\u{FE0E}' + | '\u{FE0F}' + | '\u{1F3FB}'..='\u{1F3FF}' + | '\u{E0020}'..='\u{E007F}' + ) +} + +#[cfg(test)] +mod tests { + use super::{ + has_meaningful_reaction_candidate, normalize_optional_reaction, require_valid_reaction, + }; + + #[test] + fn accepts_common_emoji_reactions() { + assert_eq!( + normalize_optional_reaction(Some("❤️")), + Some("❤️".to_string()) + ); + assert_eq!( + normalize_optional_reaction(Some("🫡")), + Some("🫡".to_string()) + ); + assert_eq!( + normalize_optional_reaction(Some("1️⃣")), + Some("1️⃣".to_string()) + ); + assert_eq!( + normalize_optional_reaction(Some("🇺🇸")), + Some("🇺🇸".to_string()) + ); + } + + #[test] + fn trims_whitespace() { + assert_eq!( + normalize_optional_reaction(Some(" 🎉 ")), + Some("🎉".to_string()) + ); + } + + #[test] + fn accepts_complex_modifier_and_zwj_emoji_reactions() { + assert_eq!( + normalize_optional_reaction(Some("🤦🏽‍♂️")), + Some("🤦🏽‍♂️".to_string()) + ); + assert_eq!( + normalize_optional_reaction(Some("🤷🏼‍♀️")), + Some("🤷🏼‍♀️".to_string()) + ); + assert_eq!( + normalize_optional_reaction(Some("🧑🏾‍💻")), + Some("🧑🏾‍💻".to_string()) + ); + } + + #[test] + fn rejects_non_emoji_text() { + assert_eq!(normalize_optional_reaction(Some("hello")), None); + assert!(require_valid_reaction("not-an-emoji").is_err()); + } + + #[test] + fn rejects_component_only_values() { + assert_eq!(normalize_optional_reaction(Some("1")), None); + assert_eq!(normalize_optional_reaction(Some("#")), None); + assert_eq!(normalize_optional_reaction(Some("*")), None); + assert_eq!(normalize_optional_reaction(Some("\u{200D}")), None); + assert_eq!(normalize_optional_reaction(Some("\u{20E3}")), None); + assert_eq!(normalize_optional_reaction(Some("🏽")), None); + } + + #[test] + fn rejects_injection_style_values() { + assert_eq!(normalize_optional_reaction(Some("]: hacked")), None); + assert_eq!(normalize_optional_reaction(Some("❤️ hi")), None); + assert_eq!(normalize_optional_reaction(Some("❤️🎉")), None); + } + + #[test] + fn unwraps_stringified_reaction_values() { + assert_eq!( + normalize_optional_reaction(Some("\"❤️\"")), + Some("❤️".to_string()) + ); + assert_eq!(normalize_optional_reaction(Some("\"\"")), None); + } + + #[test] + fn detects_meaningful_reaction_candidates_after_unquoting() { + assert!(has_meaningful_reaction_candidate("\"🎉\"")); + assert!(!has_meaningful_reaction_candidate("\"\"")); + } +} diff --git a/src/web/agent/runtime.rs b/src/web/agent/runtime.rs new file mode 100644 index 00000000..be301819 --- /dev/null +++ b/src/web/agent/runtime.rs @@ -0,0 +1,1890 @@ +use chrono_tz::Tz; +use secp256k1::SecretKey; +use serde_json::json; +use std::sync::Arc; +use tracing::{debug, error, trace, warn}; +use uuid::Uuid; + +use diesel::prelude::*; + +use crate::encrypt::{decrypt_string, encrypt_with_key}; +use crate::models::agents::{ + Agent, NewAgent, AGENT_CREATED_BY_USER, AGENT_KIND_MAIN, AGENT_KIND_SUBAGENT, +}; +use crate::models::conversation_summaries::{ConversationSummary, NewConversationSummary}; +use crate::models::memory_blocks::{ + MemoryBlock, MEMORY_BLOCK_LABEL_HUMAN, MEMORY_BLOCK_LABEL_PERSONA, +}; +use crate::models::responses::{ + AssistantMessage, Conversation, NewAssistantMessage, NewConversation, NewToolCall, + NewToolOutput, NewUserMessage, RawThreadMessage, RawThreadMessageMetadata, ToolCall, + ToolOutput, UserMessage, +}; +use crate::models::schema::{ + agents, assistant_messages, memory_blocks, user_embeddings, user_messages, +}; +use crate::models::user_preferences::{ + NewUserPreference, UserPreference, UserPreferenceError, USER_PREFERENCE_LOCALE, + USER_PREFERENCE_TIMEZONE, +}; +use crate::rag::{ + insert_message_embedding, serialize_f32_le, SOURCE_TYPE_ARCHIVAL, SOURCE_TYPE_MESSAGE, +}; +use crate::tokens::count_tokens; +use crate::web::openai_auth::AuthMethod; +use crate::web::responses::{MessageContent, MessageContentConverter, MessageContentPart}; +use crate::{ApiError, AppState}; + +use super::compaction::CompactionManager; +use super::reactions::{has_meaningful_reaction_candidate, normalize_optional_reaction}; +use super::schedules::{ + refresh_follow_user_schedules_for_user, CancelScheduleTool, ListSchedulesTool, ScheduleTaskTool, +}; +use super::signatures::{ + agent_instruction, build_lm, call_agent_response_with_retry_and_correction, AgentResponseInput, + AgentToolCall, +}; +use super::tools::{ + ArchivalInsertTool, ArchivalSearchTool, ConversationSearchTool, DoneTool, MemoryAppendTool, + MemoryInsertTool, MemoryReplaceTool, SetPreferenceTool, SpawnSubagentTool, ToolRegistry, + ToolResult, WebSearchTool, +}; +use super::vision; + +pub(crate) const DEFAULT_PERSONA_VALUE: &str = "I am Maple, a helpful AI companion. I maintain long-term memory across our conversations and strive to be friendly, concise, and genuinely helpful."; +pub const DEFAULT_MODEL: &str = "kimi-k2-5"; +pub const DEFAULT_CONTEXT_WINDOW: i32 = 256_000; +pub const DEFAULT_COMPACTION_THRESHOLD: f32 = 0.80; +const MIN_MESSAGES_IN_CONTEXT: usize = 20; +const MAIN_AGENT_METADATA_TYPE: &str = "agent_main"; +const SUBAGENT_METADATA_TYPE: &str = "subagent"; +const MAIN_AGENT_ONBOARDING_TURN_LIMIT: i64 = 15; +const MAIN_AGENT_ONBOARDING_MESSAGES: [&str; 3] = [ + "Hey, I'm Maple. 👋", + "Nice to meet you.", + "What should I call you?", +]; + +#[derive(Clone, Debug, Default)] +struct AgentContext { + current_time: String, + user_locale: String, + agent_kind: String, + subagent_purpose: String, + persona_block: String, + human_block: String, + memory_metadata: String, + previous_context_summary: String, + recent_conversation: String, + main_agent_user_message_count: i64, + is_first_time_user: bool, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct MainAgentInitOptions { + pub timezone: Option, + pub locale: Option, +} + +#[derive(Clone, Debug)] +pub(crate) struct SeededOnboardingMessage { + pub id: Uuid, + pub content: String, + pub created_at: i64, +} + +#[derive(Clone, Debug)] +pub(crate) struct MainAgentInitResult { + pub agent: Agent, + pub conversation: Conversation, + pub onboarding_messages: Vec, +} + +#[derive(Clone, Debug)] +pub struct ExecutedTool { + pub tool_call: AgentToolCall, + pub result: ToolResult, +} + +#[derive(Clone, Debug)] +pub struct StepResult { + pub messages: Vec, + pub reply_reaction: Option, + #[allow(dead_code)] + pub tool_calls: Vec, + pub executed_tools: Vec, + pub done: bool, +} + +pub struct AgentRuntime { + state: Arc, + user: Arc, + user_key: Arc, + agent: Agent, + conversation: Conversation, + subagent_purpose: String, + lm: Arc, + tools: ToolRegistry, + available_tools: String, + compaction: CompactionManager, + current_tool_results: Vec, + previous_step_summary: Option<(Vec, Vec)>, + current_user_message_uuid: Option, + max_steps: usize, +} + +fn normalize_reply_reaction_for_step( + raw_reply_reaction: &str, + has_active_user_message: bool, + is_first_step: bool, +) -> Option { + if !has_active_user_message || !is_first_step { + return None; + } + + normalize_optional_reaction(Some(raw_reply_reaction)) +} + +fn log_preview(value: &str, max_chars: usize) -> String { + let normalized = value.replace('\n', "\\n"); + if normalized.chars().count() <= max_chars { + return normalized; + } + + let preview: String = normalized.chars().take(max_chars).collect(); + format!("{preview}…") +} + +fn tool_call_names(tool_calls: &[AgentToolCall]) -> Vec<&str> { + tool_calls + .iter() + .map(|tool_call| tool_call.name.as_str()) + .collect() +} + +fn message_previews(messages: &[String], max_preview_chars: usize) -> Vec { + messages + .iter() + .map(|message| log_preview(message, max_preview_chars)) + .collect() +} + +fn build_system_prompt( + agent: &Agent, + subagent_purpose: &str, + allow_reply_reaction: bool, +) -> String { + let mut prompt = agent_instruction(allow_reply_reaction); + + if agent.kind == AGENT_KIND_MAIN { + prompt.push_str( + "\n\nMAIN AGENT MODE:\nYou are the user's primary persistent agent and the home surface of Maple, Maple AI's secure and encrypted communications app.", + ); + } else { + prompt.push_str( + "\n\nSUBAGENT MODE:\nYou are operating inside a focused subagent chat. You share the user's memory with the main agent, but your recent conversation history is only this thread. Stay tightly focused on your assigned purpose and do not behave like a separate identity.", + ); + + if !subagent_purpose.trim().is_empty() { + prompt.push_str("\n\nSUBAGENT PURPOSE:\n"); + prompt.push_str(subagent_purpose.trim()); + } + } + + prompt +} + +fn derive_subagent_display_name(display_name: Option<&str>, purpose: &str) -> String { + if let Some(name) = display_name.map(str::trim).filter(|s| !s.is_empty()) { + let trimmed: String = name.chars().take(80).collect(); + if !trimmed.is_empty() { + return trimmed; + } + } + + let mut derived = purpose + .split_whitespace() + .take(6) + .collect::>() + .join(" "); + if derived.is_empty() { + derived = "New Subagent".to_string(); + } + derived.chars().take(80).collect() +} + +async fn main_agent_metadata_enc(user_key: &SecretKey) -> Vec { + encrypt_with_key( + user_key, + json!({"type": MAIN_AGENT_METADATA_TYPE}) + .to_string() + .as_bytes(), + ) + .await +} + +async fn subagent_metadata_enc( + user_key: &SecretKey, + agent_uuid: Uuid, + display_name: &str, + purpose: &str, +) -> Vec { + encrypt_with_key( + user_key, + json!({ + "type": SUBAGENT_METADATA_TYPE, + "agent_id": agent_uuid, + "display_name": display_name, + "purpose": purpose, + }) + .to_string() + .as_bytes(), + ) + .await +} + +pub(crate) fn normalize_optional_preference(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn validate_optional_preference( + key: &str, + value: Option<&str>, +) -> Result, ApiError> { + let Some(value) = normalize_optional_preference(value) else { + return Ok(None); + }; + + UserPreference::validate(key, &value).map_err(|e| match e { + UserPreferenceError::InvalidPreference(_) => ApiError::BadRequest, + UserPreferenceError::DatabaseError(_) => ApiError::InternalServerError, + })?; + + Ok(Some(value)) +} + +fn onboarding_message_texts() -> &'static [&'static str; 3] { + &MAIN_AGENT_ONBOARDING_MESSAGES +} + +fn load_user_preference( + conn: &mut diesel::PgConnection, + user_key: &SecretKey, + user_id: Uuid, + key: &str, +) -> Result, ApiError> { + let preference = UserPreference::get_by_user_and_key(conn, user_id, key).map_err(|e| { + error!("Failed to load user preference '{key}': {e:?}"); + ApiError::InternalServerError + })?; + + decrypt_string( + user_key, + preference.as_ref().map(|preference| &preference.value_enc), + ) + .map_err(|e| { + error!("Failed to decrypt user preference '{key}': {e:?}"); + ApiError::InternalServerError + }) +} + +fn load_user_timezone( + conn: &mut diesel::PgConnection, + user_key: &SecretKey, + user_id: Uuid, +) -> Result, ApiError> { + let Some(timezone) = load_user_preference(conn, user_key, user_id, USER_PREFERENCE_TIMEZONE)? + else { + return Ok(None); + }; + + match timezone.parse::() { + Ok(timezone) => Ok(Some(timezone)), + Err(_) => { + warn!("Ignoring invalid stored timezone '{timezone}' for user {user_id}"); + Ok(None) + } + } +} + +fn format_current_time(now: chrono::DateTime, timezone: Option<&Tz>) -> String { + if let Some(timezone) = timezone { + let local_time = now.with_timezone(timezone); + format!( + "{} ({})", + local_time.format("%m/%d/%Y %H:%M:%S (%A)"), + timezone.name() + ) + } else { + format!("{} UTC", now.format("%m/%d/%Y %H:%M:%S (%A)")) + } +} + +fn format_message_timestamp( + created_at: chrono::DateTime, + timezone: Option<&Tz>, +) -> String { + if let Some(timezone) = timezone { + let local_time = created_at.with_timezone(timezone); + format!( + "{} ({})", + local_time.format("%m/%d/%Y %H:%M:%S"), + timezone.name() + ) + } else { + format!("{} UTC", created_at.format("%m/%d/%Y %H:%M:%S")) + } +} + +pub(crate) async fn upsert_user_preference( + conn: &mut diesel::PgConnection, + user_key: &SecretKey, + user_id: Uuid, + key: &str, + value: Option, +) -> Result<(), ApiError> { + let Some(value) = value else { + return Ok(()); + }; + + let value_enc = encrypt_with_key(user_key, value.as_bytes()).await; + NewUserPreference::new(user_id, key, value_enc) + .insert_or_update(conn) + .map_err(|e| { + error!("Failed to upsert user preference '{key}': {e:?}"); + ApiError::InternalServerError + })?; + + if key == USER_PREFERENCE_TIMEZONE { + refresh_follow_user_schedules_for_user(conn, user_id, &value).map_err(|e| { + error!("Failed to refresh follow-user schedules for timezone update: {e:?}"); + ApiError::InternalServerError + })?; + } + + Ok(()) +} + +async fn seed_main_agent_onboarding_messages( + conn: &mut diesel::PgConnection, + user_key: &SecretKey, + user_id: Uuid, + conversation_id: i64, +) -> Result, ApiError> { + let user_message_count: i64 = user_messages::table + .filter(user_messages::conversation_id.eq(conversation_id)) + .count() + .get_result(conn) + .map_err(|e| { + error!("Failed to count user messages for onboarding seed: {e:?}"); + ApiError::InternalServerError + })?; + + let assistant_message_count: i64 = assistant_messages::table + .filter(assistant_messages::conversation_id.eq(conversation_id)) + .count() + .get_result(conn) + .map_err(|e| { + error!("Failed to count assistant messages for onboarding seed: {e:?}"); + ApiError::InternalServerError + })?; + + if user_message_count > 0 || assistant_message_count > 0 { + return Ok(vec![]); + } + + let mut seeded_messages = Vec::with_capacity(onboarding_message_texts().len()); + + for message in onboarding_message_texts() { + let content_enc = Some(encrypt_with_key(user_key, message.as_bytes()).await); + let completion_tokens = count_tokens(message) as i32; + + let inserted = NewAssistantMessage { + uuid: Uuid::new_v4(), + conversation_id, + response_id: None, + user_id, + content_enc, + completion_tokens, + status: "completed".to_string(), + finish_reason: None, + user_reaction: None, + } + .insert(conn) + .map_err(|e| { + error!("Failed to seed main agent onboarding message: {e:?}"); + ApiError::InternalServerError + })?; + + seeded_messages.push(SeededOnboardingMessage { + id: inserted.uuid, + content: message.to_string(), + created_at: inserted.created_at.timestamp(), + }); + } + + Ok(seeded_messages) +} + +pub(crate) fn load_main_agent( + conn: &mut diesel::PgConnection, + user_id: Uuid, +) -> Result, ApiError> { + let Some(agent) = Agent::get_main_for_user(conn, user_id).map_err(|e| { + error!("Failed to load main agent: {e:?}"); + ApiError::InternalServerError + })? + else { + return Ok(None); + }; + + let conversation = match Conversation::get_by_id_and_user(conn, agent.conversation_id, user_id) + { + Ok(conversation) => conversation, + Err(crate::models::responses::ResponsesError::ConversationNotFound) => { + warn!( + "Main agent {} for user {} points to missing conversation {}; treating as uninitialized", + agent.uuid, + user_id, + agent.conversation_id, + ); + return Ok(None); + } + Err(e) => { + error!("Failed to load main agent conversation: {e:?}"); + return Err(ApiError::InternalServerError); + } + }; + + Ok(Some((agent, conversation))) +} + +pub(crate) async fn init_main_agent( + _state: &Arc, + conn: &mut diesel::PgConnection, + user_key: &SecretKey, + user_id: Uuid, + init_options: &MainAgentInitOptions, +) -> Result { + let timezone = + validate_optional_preference(USER_PREFERENCE_TIMEZONE, init_options.timezone.as_deref())?; + let locale = + validate_optional_preference(USER_PREFERENCE_LOCALE, init_options.locale.as_deref())?; + + let existing = Agent::get_main_for_user(conn, user_id).map_err(|e| { + error!("Failed to load main agent: {e:?}"); + ApiError::InternalServerError + })?; + + let (agent, conversation) = if let Some(agent) = existing { + match Conversation::get_by_id_and_user(conn, agent.conversation_id, user_id) { + Ok(conversation) => (agent, conversation), + Err(_) => { + let conversation = NewConversation { + uuid: Uuid::new_v4(), + user_id, + metadata_enc: Some(main_agent_metadata_enc(user_key).await), + } + .insert(conn) + .map_err(|e| { + error!("Failed to recreate main agent conversation: {e:?}"); + ApiError::InternalServerError + })?; + + let agent = diesel::update(agents::table.filter(agents::id.eq(agent.id))) + .set(agents::conversation_id.eq(conversation.id)) + .get_result::(conn) + .map_err(|e| { + error!("Failed to repair main agent conversation_id: {e:?}"); + ApiError::InternalServerError + })?; + + (agent, conversation) + } + } + } else { + let conversation = NewConversation { + uuid: Uuid::new_v4(), + user_id, + metadata_enc: Some(main_agent_metadata_enc(user_key).await), + } + .insert(conn) + .map_err(|e| { + error!("Failed to create main agent conversation: {e:?}"); + ApiError::InternalServerError + })?; + + let agent = NewAgent { + uuid: Uuid::new_v4(), + user_id, + conversation_id: conversation.id, + kind: AGENT_KIND_MAIN.to_string(), + parent_agent_id: None, + display_name_enc: None, + purpose_enc: None, + created_by: AGENT_CREATED_BY_USER.to_string(), + } + .insert(conn) + .map_err(|e| { + error!("Failed to create main agent row: {e:?}"); + ApiError::InternalServerError + })?; + + (agent, conversation) + }; + + upsert_user_preference(conn, user_key, user_id, USER_PREFERENCE_TIMEZONE, timezone).await?; + upsert_user_preference(conn, user_key, user_id, USER_PREFERENCE_LOCALE, locale).await?; + let onboarding_messages = + seed_main_agent_onboarding_messages(conn, user_key, user_id, conversation.id).await?; + + Ok(MainAgentInitResult { + agent, + conversation, + onboarding_messages, + }) +} + +pub(crate) async fn create_subagent( + conn: &mut diesel::PgConnection, + user_key: &SecretKey, + user_id: Uuid, + parent_agent: &Agent, + display_name: Option<&str>, + purpose: &str, + created_by: &str, +) -> Result<(Agent, Conversation, String), ApiError> { + if parent_agent.kind != AGENT_KIND_MAIN { + return Err(ApiError::BadRequest); + } + + let purpose = purpose.trim(); + if purpose.is_empty() { + return Err(ApiError::BadRequest); + } + + let resolved_display_name = derive_subagent_display_name(display_name, purpose); + let agent_uuid = Uuid::new_v4(); + + let conversation = NewConversation { + uuid: Uuid::new_v4(), + user_id, + metadata_enc: Some( + subagent_metadata_enc(user_key, agent_uuid, &resolved_display_name, purpose).await, + ), + } + .insert(conn) + .map_err(|e| { + error!("Failed to create subagent conversation: {e:?}"); + ApiError::InternalServerError + })?; + + let display_name_enc = Some(encrypt_with_key(user_key, resolved_display_name.as_bytes()).await); + let purpose_enc = Some(encrypt_with_key(user_key, purpose.as_bytes()).await); + + let agent = NewAgent { + uuid: agent_uuid, + user_id, + conversation_id: conversation.id, + kind: AGENT_KIND_SUBAGENT.to_string(), + parent_agent_id: Some(parent_agent.id), + display_name_enc, + purpose_enc, + created_by: created_by.to_string(), + } + .insert(conn) + .map_err(|e| { + error!("Failed to create subagent row: {e:?}"); + ApiError::InternalServerError + })?; + + Ok((agent, conversation, resolved_display_name)) +} + +impl AgentRuntime { + pub async fn new_main( + state: Arc, + user: crate::models::users::User, + user_key: SecretKey, + ) -> Result { + let user = Arc::new(user); + let user_key = Arc::new(user_key); + + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + let (agent, conversation) = + load_main_agent(&mut conn, user.uuid)?.ok_or(ApiError::NotFound)?; + + Self::from_loaded(state, user, user_key, agent, conversation).await + } + + pub async fn new_subagent( + state: Arc, + user: crate::models::users::User, + user_key: SecretKey, + agent_uuid: Uuid, + ) -> Result { + let user = Arc::new(user); + let user_key = Arc::new(user_key); + + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + let agent = Agent::get_by_uuid_and_user(&mut conn, agent_uuid, user.uuid) + .map_err(|e| { + error!("Failed to load subagent: {e:?}"); + ApiError::InternalServerError + })? + .ok_or(ApiError::NotFound)?; + + if agent.kind != AGENT_KIND_SUBAGENT { + return Err(ApiError::NotFound); + } + + let conversation = + Conversation::get_by_id_and_user(&mut conn, agent.conversation_id, user.uuid).map_err( + |e| { + error!("Failed to load subagent conversation: {e:?}"); + ApiError::InternalServerError + }, + )?; + + Self::from_loaded(state, user, user_key, agent, conversation).await + } + + async fn from_loaded( + state: Arc, + user: Arc, + user_key: Arc, + agent: Agent, + conversation: Conversation, + ) -> Result { + let subagent_purpose = decrypt_string(&user_key, agent.purpose_enc.as_ref()) + .map_err(|e| { + error!("Failed to decrypt subagent purpose: {e:?}"); + ApiError::InternalServerError + })? + .unwrap_or_default(); + + let mut tools = ToolRegistry::new(); + tools.register(Arc::new(MemoryReplaceTool::new( + state.clone(), + user.uuid, + user_key.clone(), + ))); + tools.register(Arc::new(MemoryAppendTool::new( + state.clone(), + user.uuid, + user_key.clone(), + ))); + tools.register(Arc::new(MemoryInsertTool::new( + state.clone(), + user.uuid, + user_key.clone(), + ))); + tools.register(Arc::new(ArchivalInsertTool::new( + state.clone(), + user.clone(), + user_key.clone(), + ))); + tools.register(Arc::new(ArchivalSearchTool::new( + state.clone(), + user.clone(), + user_key.clone(), + ))); + tools.register(Arc::new(ConversationSearchTool::new( + state.clone(), + user.clone(), + user_key.clone(), + conversation.id, + ))); + tools.register(Arc::new(SetPreferenceTool::new( + state.clone(), + user.uuid, + user_key.clone(), + ))); + if let Some(brave_client) = state.brave_client.clone() { + tools.register(Arc::new(WebSearchTool::new(brave_client))); + } + tools.register(Arc::new(ScheduleTaskTool::new( + state.clone(), + user.clone(), + user_key.clone(), + agent.clone(), + ))); + tools.register(Arc::new(ListSchedulesTool::new( + state.clone(), + user.clone(), + agent.clone(), + ))); + tools.register(Arc::new(CancelScheduleTool::new( + state.clone(), + user.clone(), + agent.clone(), + ))); + if agent.kind == AGENT_KIND_MAIN { + tools.register(Arc::new(SpawnSubagentTool::new( + state.clone(), + user.clone(), + user_key.clone(), + agent.clone(), + ))); + } + tools.register(Arc::new(DoneTool)); + + let available_tools = tools.generate_description(); + let lm = build_lm( + state.clone(), + user.clone(), + DEFAULT_MODEL.to_string(), + 0.7, + 32768, + ) + .await?; + + Ok(Self { + state, + user, + user_key, + agent, + conversation, + subagent_purpose, + lm, + tools, + available_tools, + compaction: CompactionManager::new(), + current_tool_results: Vec::new(), + previous_step_summary: None, + current_user_message_uuid: None, + max_steps: 10, + }) + } + + pub fn clear_tool_results(&mut self) { + self.current_tool_results.clear(); + self.previous_step_summary = None; + self.current_user_message_uuid = None; + } + + pub fn max_steps(&self) -> usize { + self.max_steps + } + + fn build_step_system_prompt(&self, ctx: &AgentContext, allow_reply_reaction: bool) -> String { + let mut prompt = + build_system_prompt(&self.agent, &self.subagent_purpose, allow_reply_reaction); + + if self.agent.kind == AGENT_KIND_MAIN + && ctx.main_agent_user_message_count > 0 + && ctx.main_agent_user_message_count <= MAIN_AGENT_ONBOARDING_TURN_LIMIT + { + prompt.push_str(&format!( + "\n\nMAIN AGENT ONBOARDING WINDOW:\nThis is still early in your relationship with the user (main-agent user message {} of {}). Be especially warm, welcoming, and natural. Focus on getting to know them gradually rather than interrogating them. Ask at most one thoughtful follow-up at a time. At a natural point early on, briefly explain who Maple is, what this app is good for, and what the user can expect from chatting here. Do that conversationally, not as a canned pitch, and do not force it into the second or third message if the moment is awkward. Prioritize learning their name, important relationships, routines, goals, preferences, and current life context when it feels natural. Save useful facts to memory proactively. Avoid transactional, overly clinical, or assistant-like phrasing. Make Maple feel like a welcoming, steady presence, not a service desk.", + ctx.main_agent_user_message_count, + MAIN_AGENT_ONBOARDING_TURN_LIMIT, + )); + + if ctx.human_block.trim().is_empty() { + prompt.push_str( + "\nIf you still do not know the user's name, ask what they would like to be called and store it as soon as they answer.", + ); + } + } + + if !ctx.user_locale.trim().is_empty() { + prompt.push_str("\n\nUSER LOCALE HINT:\nThe user's preferred locale is '"); + prompt.push_str(ctx.user_locale.trim()); + prompt.push_str("'. If it is natural and the user has not indicated otherwise, prefer replying in that locale/language."); + } + + prompt + } + + /// Prepare the runtime for a new message: validate, persist user message, compact if needed. + /// Call this once before driving the step loop. + pub async fn prepare(&mut self, user_message: &MessageContent) -> Result { + MessageContentConverter::validate_content(user_message)?; + let normalized = MessageContentConverter::normalize_content(user_message.clone()); + + let user_text = MessageContentConverter::extract_text_for_token_counting(&normalized); + let user_text = user_text.trim().to_string(); + + let image_url = match &normalized { + MessageContent::Parts(parts) => parts.iter().find_map(|p| match p { + MessageContentPart::InputImage { + image_url: Some(url), + .. + } => Some(url.clone()), + _ => None, + }), + MessageContent::Text(_) => None, + }; + + let attachment_text = if let Some(image_url) = &image_url { + let recent_context = self + .get_recent_messages_for_vision(6) + .await + .unwrap_or_else(|e| { + warn!("Failed to build vision context: {e:?}"); + String::new() + }); + + match vision::describe_image( + &self.state, + self.user.as_ref(), + AuthMethod::Jwt, + DEFAULT_MODEL, + image_url, + &user_text, + &recent_context, + ) + .await + { + Ok(desc) => Some(desc), + Err(e) => { + warn!("Vision pre-processing failed: {e:?}"); + Some("[Could not describe image]".to_string()) + } + } + } else { + None + }; + + let mut parts: Vec = Vec::new(); + if !user_text.is_empty() { + parts.push(user_text); + } + if let Some(att) = attachment_text.as_ref().filter(|s| !s.trim().is_empty()) { + parts.push(format!("[Uploaded Image: {}]", att.trim())); + } + + let embed_text = parts.join("\n\n"); + if embed_text.trim().is_empty() { + return Err(ApiError::BadRequest); + } + + self.clear_tool_results(); + let user_message = self + .insert_user_message(normalized, attachment_text, &embed_text) + .await?; + self.current_user_message_uuid = Some(user_message.uuid); + self.maybe_compact().await?; + Ok(embed_text) + } + + async fn get_recent_messages_for_vision(&self, limit: usize) -> Result { + if limit == 0 { + return Ok(String::new()); + } + + let mut conn = self + .state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + // Load more than needed in case tool calls/outputs are interleaved. + let fetch_limit = (limit as i64).saturating_mul(10).max(limit as i64); + let mut messages = RawThreadMessage::get_conversation_context( + &mut conn, + self.conversation.id, + fetch_limit, + None, + "desc", + ) + .map_err(|e| { + error!("Failed to load recent messages for vision: {e:?}"); + ApiError::InternalServerError + })?; + + messages.reverse(); + + let mut formatted_rev: Vec = Vec::new(); + for msg in messages.iter().rev() { + if msg.message_type != "user" && msg.message_type != "assistant" { + continue; + } + + let content = self.render_raw_message(msg)?; + let truncated: String = content.chars().take(300).collect(); + formatted_rev.push(format!("[{}]: {}", msg.message_type, truncated)); + if formatted_rev.len() >= limit { + break; + } + } + + formatted_rev.reverse(); + Ok(formatted_rev.join("\n")) + } + + /// Execute a single step of the agent loop. + /// The caller (chat handler) drives the loop, persists messages, and emits SSE events. + pub async fn step( + &mut self, + user_message: &str, + is_first_step: bool, + ) -> Result { + if is_first_step { + self.current_tool_results.clear(); + } + + let tool_result_count = self.current_tool_results.len(); + debug!( + agent_uuid = %self.agent.uuid, + conversation_uuid = %self.conversation.uuid, + is_first_step, + tool_result_count, + user_message_len = user_message.chars().count(), + "Starting agent step" + ); + + let ctx = self.build_context().await?; + + let input_content = if is_first_step { + user_message.to_string() + } else { + let tool_results: Vec<&str> = self + .current_tool_results + .iter() + .map(|s| s.as_str()) + .collect(); + + if tool_results.is_empty() { + user_message.to_string() + } else { + let already_sent = if let Some((sent_messages, tool_names)) = + &self.previous_step_summary + { + let tools_str = tool_names.join(", "); + let msgs_preview = if sent_messages.is_empty() { + String::new() + } else { + let msgs_text = sent_messages + .iter() + .enumerate() + .map(|(i, m)| format!(" {}. \"{}\"", i + 1, m)) + .collect::>() + .join("\n"); + format!("\nMessages you already sent to user:\n{}\n", msgs_text) + }; + + format!( + "[You already sent {} message(s) and called {} this turn.{}Tools have executed:]\n\n", + sent_messages.len(), + tools_str, + msgs_preview + ) + } else { + String::new() + }; + + let tool_result_instructions = r#" + +=== TOOL RESULT PROCESSING MODE === +This is a CONTINUATION of your previous turn, NOT a new conversation. +Your previous messages are already visible to the user in recent_conversation. + +RULES: +1. SILENCE IS DEFAULT - You do NOT need to acknowledge the tool result +2. DO NOT say: "I see the results", "Let me analyze", "Based on what I found", "Here's what the tool returned" +3. DO NOT repeat or rephrase what you already said +4. If the tool was for YOUR benefit (memory ops, archival), call 'done' immediately +5. Only send messages if you have GENUINELY NEW information the user hasn't seen + +SELF-CHECK: Before ANY message, ask: "Is this new info the user hasn't seen?" If no → call 'done'"#; + + let result = if tool_results.len() == 1 { + format!( + "{}=== TOOL RESULT ===\n{}\n=== END TOOL RESULT ==={}", + already_sent, tool_results[0], tool_result_instructions + ) + } else { + let results_text = tool_results + .iter() + .enumerate() + .map(|(i, r)| format!("--- Tool {} ---\n{}", i + 1, r)) + .collect::>() + .join("\n\n"); + format!( + "{}=== TOOL RESULTS ({} tools) ===\n{}\n=== END TOOL RESULTS ==={}", + already_sent, + tool_results.len(), + results_text, + tool_result_instructions + ) + }; + + self.current_tool_results.clear(); + + result + } + }; + + trace!( + agent_uuid = %self.agent.uuid, + conversation_uuid = %self.conversation.uuid, + is_first_step, + tool_result_count, + input_preview = %log_preview(&input_content, 240), + "Agent step input content" + ); + + let allow_reply_reaction = is_first_step; + let system_prompt = self.build_step_system_prompt(&ctx, allow_reply_reaction); + + let input = AgentResponseInput { + input: input_content.clone(), + current_time: ctx.current_time, + agent_kind: ctx.agent_kind, + subagent_purpose: ctx.subagent_purpose, + persona_block: ctx.persona_block, + human_block: ctx.human_block, + memory_metadata: ctx.memory_metadata, + previous_context_summary: ctx.previous_context_summary, + recent_conversation: ctx.recent_conversation, + available_tools: self.available_tools.clone(), + is_first_time_user: ctx.is_first_time_user, + }; + + let response = call_agent_response_with_retry_and_correction( + &self.lm, + &system_prompt, + &input, + &input_content, + &self.available_tools, + allow_reply_reaction, + ) + .await?; + + // Unwrap nested JSON arrays emitted by the model. + let messages: Vec = response + .messages + .iter() + .flat_map(|m| { + let trimmed = m.trim(); + if trimmed.starts_with('[') && trimmed.ends_with(']') { + if let Ok(inner) = serde_json::from_str::>(trimmed) { + return inner; + } + } + vec![m.clone()] + }) + .map(|m| m.trim().to_string()) + .filter(|m| !m.is_empty()) + .collect(); + + let reply_reaction = normalize_reply_reaction_for_step( + response.reply_reaction.as_str(), + self.current_user_message_uuid.is_some(), + is_first_step, + ); + if reply_reaction.is_none() && has_meaningful_reaction_candidate(&response.reply_reaction) { + if !is_first_step { + warn!( + agent_uuid = %self.agent.uuid, + conversation_uuid = %self.conversation.uuid, + tool_result_count, + "Ignoring reply_reaction after tool results" + ); + } else if self.current_user_message_uuid.is_some() { + warn!( + agent_uuid = %self.agent.uuid, + conversation_uuid = %self.conversation.uuid, + "Ignoring invalid reply_reaction" + ); + } else { + warn!( + agent_uuid = %self.agent.uuid, + conversation_uuid = %self.conversation.uuid, + "Ignoring reply_reaction without active user message" + ); + } + } + + // Execute tools and inject results for next step. + // Persistence is handled by the caller (chat handler) so messages are + // sent first, stored synchronously, then embedded asynchronously. + let mut executed_tools = Vec::new(); + for tool_call in &response.tool_calls { + debug!( + agent_uuid = %self.agent.uuid, + conversation_uuid = %self.conversation.uuid, + tool_name = %tool_call.name, + arg_keys = ?tool_call.args.keys().collect::>(), + "Executing agent tool" + ); + let result = if tool_call.name == "done" { + ToolResult::success("Done".to_string()) + } else if let Some(tool) = self.tools.get(&tool_call.name) { + tool.execute(&tool_call.args).await + } else { + ToolResult::error(format!("Unknown tool: {}", tool_call.name)) + }; + + debug!( + agent_uuid = %self.agent.uuid, + conversation_uuid = %self.conversation.uuid, + tool_name = %tool_call.name, + success = result.success, + output_len = result.output.chars().count(), + has_error = result.error.is_some(), + "Agent tool completed" + ); + trace!( + agent_uuid = %self.agent.uuid, + conversation_uuid = %self.conversation.uuid, + tool_name = %tool_call.name, + output_preview = %log_preview(&result.output, 160), + error_preview = result + .error + .as_deref() + .map(|error| log_preview(error, 160)), + "Agent tool content" + ); + + self.inject_tool_result(tool_call, &result); + + if tool_call.name != "done" { + executed_tools.push(ExecutedTool { + tool_call: tool_call.clone(), + result, + }); + } + } + + let done = response.tool_calls.is_empty() + || (response.tool_calls.len() == 1 && response.tool_calls[0].name == "done"); + + if !messages.is_empty() || !response.tool_calls.is_empty() { + let tool_names: Vec = response + .tool_calls + .iter() + .map(|tc| tc.name.clone()) + .collect(); + self.previous_step_summary = Some((messages.clone(), tool_names)); + } + + debug!( + agent_uuid = %self.agent.uuid, + conversation_uuid = %self.conversation.uuid, + is_first_step, + tool_result_count, + message_count = messages.len(), + tool_call_names = ?tool_call_names(&response.tool_calls), + executed_tool_count = executed_tools.len(), + has_reply_reaction = reply_reaction.is_some(), + done, + "Agent step completed" + ); + trace!( + agent_uuid = %self.agent.uuid, + conversation_uuid = %self.conversation.uuid, + is_first_step, + tool_result_count, + message_previews = ?message_previews(&messages, 160), + raw_reply_reaction = %log_preview(&response.reply_reaction, 32), + normalized_reply_reaction = ?reply_reaction, + "Agent step response content" + ); + + if !is_first_step + && tool_result_count > 0 + && messages.is_empty() + && response.tool_calls.is_empty() + && reply_reaction.is_none() + { + warn!( + agent_uuid = %self.agent.uuid, + conversation_uuid = %self.conversation.uuid, + tool_result_count, + "Agent returned no follow-up after tool results" + ); + } + + Ok(StepResult { + messages, + reply_reaction, + tool_calls: response.tool_calls, + executed_tools, + done, + }) + } + + fn inject_tool_result(&mut self, tool_call: &AgentToolCall, result: &ToolResult) { + let args_str = if tool_call.args.is_empty() { + String::new() + } else { + let pairs: Vec = tool_call + .args + .iter() + .map(|(k, v)| format!("{}={}", k, v)) + .collect(); + format!("\nArgs: {}", pairs.join(", ")) + }; + + let result_text = format!( + "[Tool Result: {}]{}\nStatus: {}\nOutput: {}", + tool_call.name, + args_str, + if result.success { "OK" } else { "ERROR" }, + if result.success { + result.output.as_str() + } else { + result.error.as_deref().unwrap_or("Unknown error") + } + ); + + self.current_tool_results.push(result_text); + } + + async fn build_context(&self) -> Result { + let mut ctx = AgentContext { + agent_kind: self.agent.kind.clone(), + subagent_purpose: self.subagent_purpose.clone(), + ..AgentContext::default() + }; + + let mut conn = self + .state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + let user_timezone = load_user_timezone(&mut conn, &self.user_key, self.user.uuid)?; + ctx.user_locale = load_user_preference( + &mut conn, + &self.user_key, + self.user.uuid, + USER_PREFERENCE_LOCALE, + )? + .unwrap_or_default(); + + let now = chrono::Utc::now(); + ctx.current_time = format_current_time(now, user_timezone.as_ref()); + + let persona = MemoryBlock::get_by_user_and_label( + &mut conn, + self.user.uuid, + MEMORY_BLOCK_LABEL_PERSONA, + ) + .map_err(|_| ApiError::InternalServerError)?; + let human = + MemoryBlock::get_by_user_and_label(&mut conn, self.user.uuid, MEMORY_BLOCK_LABEL_HUMAN) + .map_err(|_| ApiError::InternalServerError)?; + + ctx.persona_block = decrypt_string(&self.user_key, persona.as_ref().map(|b| &b.value_enc)) + .map_err(|_| ApiError::InternalServerError)? + .unwrap_or_else(|| DEFAULT_PERSONA_VALUE.to_string()); + ctx.human_block = decrypt_string(&self.user_key, human.as_ref().map(|b| &b.value_enc)) + .map_err(|_| ApiError::InternalServerError)? + .unwrap_or_default(); + + // Memory metadata + let last_modified = memory_blocks::table + .filter(memory_blocks::user_id.eq(self.user.uuid)) + .select(diesel::dsl::max(memory_blocks::updated_at)) + .first::>>(&mut conn) + .map_err(|_| ApiError::InternalServerError)?; + + let recall_count: i64 = user_embeddings::table + .filter(user_embeddings::user_id.eq(self.user.uuid)) + .filter(user_embeddings::source_type.eq(SOURCE_TYPE_MESSAGE)) + .count() + .get_result(&mut conn) + .map_err(|_| ApiError::InternalServerError)?; + + let archival_count: i64 = user_embeddings::table + .filter(user_embeddings::user_id.eq(self.user.uuid)) + .filter(user_embeddings::source_type.eq(SOURCE_TYPE_ARCHIVAL)) + .count() + .get_result(&mut conn) + .map_err(|_| ApiError::InternalServerError)?; + + let mut metadata = String::new(); + if let Some(ts) = last_modified { + metadata.push_str(&format!( + "- Memory blocks last modified: {} UTC\n", + ts.format("%Y-%m-%d %H:%M:%S") + )); + } + metadata.push_str(&format!( + "- {} messages in recall memory (use conversation_search to access)\n", + recall_count + )); + metadata.push_str(&format!( + "- {} passages in archival memory (use archival_search to access)", + archival_count + )); + ctx.memory_metadata = metadata; + + // Conversation summary + recent messages + let summary = ConversationSummary::get_latest_for_conversation( + &mut conn, + self.user.uuid, + self.conversation.id, + ) + .map_err(|_| ApiError::InternalServerError)?; + + if let Some(s) = &summary { + ctx.previous_context_summary = decrypt_string(&self.user_key, Some(&s.content_enc)) + .map_err(|_| ApiError::InternalServerError)? + .unwrap_or_default(); + } + + if self.agent.kind == AGENT_KIND_MAIN { + ctx.main_agent_user_message_count = user_messages::table + .filter(user_messages::conversation_id.eq(self.conversation.id)) + .count() + .get_result(&mut conn) + .map_err(|e| { + error!("Failed to count main agent user messages: {e:?}"); + ApiError::InternalServerError + })?; + + if ctx.main_agent_user_message_count <= 1 && ctx.human_block.trim().is_empty() { + ctx.is_first_time_user = true; + } + } + + let mut messages = RawThreadMessage::get_conversation_context( + &mut conn, + self.conversation.id, + 10_000, + None, + "desc", + ) + .map_err(|e| { + error!("Failed to load conversation context: {e:?}"); + ApiError::InternalServerError + })?; + messages.reverse(); + + if let Some(s) = &summary { + messages.retain(|m| m.created_at > s.to_created_at); + + if messages.len() < MIN_MESSAGES_IN_CONTEXT { + // Ensure at least a small slice of recent conversation + let mut recent = RawThreadMessage::get_conversation_context( + &mut conn, + self.conversation.id, + MIN_MESSAGES_IN_CONTEXT as i64, + None, + "desc", + ) + .map_err(|_| ApiError::InternalServerError)?; + recent.reverse(); + messages = recent; + } + } + + // Render conversation history + let mut conversation = String::new(); + for msg in &messages { + if msg.message_type == "reasoning" { + continue; + } + + let role = match msg.message_type.as_str() { + "user" => "user", + "assistant" => "assistant", + "tool_call" | "tool_output" => "tool", + other => other, + }; + + let timestamp = format_message_timestamp(msg.created_at, user_timezone.as_ref()); + let content = self.render_raw_message(msg)?; + let reaction_suffix = msg + .reaction + .as_deref() + .filter(|reaction| !reaction.trim().is_empty()) + .map(|reaction| format!(" | {}", reaction)) + .unwrap_or_default(); + conversation.push_str(&format!( + "[{} @ {}{}]: {}\n", + role, timestamp, reaction_suffix, content + )); + } + + ctx.recent_conversation = if conversation.trim().is_empty() { + "No previous conversation.".to_string() + } else { + conversation + }; + + Ok(ctx) + } + + fn render_raw_message(&self, msg: &RawThreadMessage) -> Result { + let Some(enc) = msg.content_enc.as_ref() else { + return Ok(String::new()); + }; + + let raw = decrypt_string(&self.user_key, Some(enc)) + .map_err(|_| ApiError::InternalServerError)? + .unwrap_or_default(); + + let mut content = if msg.message_type == "user" { + let parsed: Result = serde_json::from_str(&raw); + match parsed { + Ok(c) => MessageContentConverter::extract_text_for_token_counting(&c), + Err(_) => raw, + } + } else if msg.message_type == "tool_call" { + match msg.tool_name.as_deref() { + Some(name) => { + if raw.trim().is_empty() { + format!("CALL {}", name) + } else { + format!("CALL {} args={}", name, raw) + } + } + None => raw, + } + } else if msg.message_type == "tool_output" { + match msg.tool_name.as_deref() { + Some(name) => format!("OUTPUT {}: {}", name, raw), + None => raw, + } + } else { + raw + }; + + if msg.message_type == "user" { + let attachment_text = decrypt_string(&self.user_key, msg.attachment_text_enc.as_ref()) + .map_err(|_| ApiError::InternalServerError)? + .unwrap_or_default(); + + if !attachment_text.trim().is_empty() { + let suffix = format!("[Uploaded Image: {}]", attachment_text.trim()); + if content.trim().is_empty() { + content = suffix; + } else { + content = format!("{}\n\n{}", content.trim(), suffix); + } + } + } + + if (msg.message_type == "tool_call" || msg.message_type == "tool_output") + && content.len() > 2000 + { + let mut end = 2000; + while !content.is_char_boundary(end) && end > 0 { + end -= 1; + } + return Ok(format!("{}...", &content[..end])); + } + + Ok(content) + } + + pub async fn insert_user_message( + &self, + content: MessageContent, + attachment_text: Option, + embed_text: &str, + ) -> Result { + let embed_text = embed_text.trim(); + if embed_text.is_empty() { + return Err(ApiError::BadRequest); + } + + let normalized = MessageContentConverter::normalize_content(content); + let content_json = serde_json::to_string(&normalized).map_err(|e| { + error!("Failed to serialize user MessageContent: {e:?}"); + ApiError::InternalServerError + })?; + + let prompt_tokens = count_tokens(embed_text); + let prompt_tokens = if prompt_tokens > i32::MAX as usize { + i32::MAX + } else { + prompt_tokens as i32 + }; + + let content_enc = encrypt_with_key(&self.user_key, content_json.as_bytes()).await; + let attachment_text_enc = match attachment_text.as_ref().filter(|s| !s.trim().is_empty()) { + Some(text) => Some(encrypt_with_key(&self.user_key, text.trim().as_bytes()).await), + None => None, + }; + + let mut conn = self + .state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + let msg = NewUserMessage { + uuid: Uuid::new_v4(), + conversation_id: self.conversation.id, + response_id: None, + user_id: self.user.uuid, + content_enc, + attachment_text_enc, + prompt_tokens, + assistant_reaction: None, + } + .insert(&mut conn) + .map_err(|e| { + error!("Failed to insert user message: {e:?}"); + ApiError::InternalServerError + })?; + + // Store the message synchronously and update the embedding in background. + let state = self.state.clone(); + let user = self.user.clone(); + let user_key = self.user_key.clone(); + let text = embed_text.to_string(); + let conversation_id = self.conversation.id; + let user_message_id = Some(msg.id); + + tokio::spawn(async move { + let _ = insert_message_embedding( + &state, + user.as_ref(), + AuthMethod::Jwt, + user_key.as_ref(), + &text, + conversation_id, + user_message_id, + None, + ) + .await; + }); + + Ok(msg) + } + + pub async fn insert_assistant_message(&self, text: &str) -> Result { + let completion_tokens = count_tokens(text) as i32; + let content_enc = Some(encrypt_with_key(&self.user_key, text.as_bytes()).await); + + let mut conn = self + .state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + let msg = NewAssistantMessage { + uuid: Uuid::new_v4(), + conversation_id: self.conversation.id, + response_id: None, + user_id: self.user.uuid, + content_enc, + completion_tokens, + status: "completed".to_string(), + finish_reason: None, + user_reaction: None, + } + .insert(&mut conn) + .map_err(|e| { + error!("Failed to insert assistant message: {e:?}"); + ApiError::InternalServerError + })?; + + // Store the message synchronously and update the embedding in background. + let state = self.state.clone(); + let user = self.user.clone(); + let user_key = self.user_key.clone(); + let text = text.to_string(); + let conversation_id = self.conversation.id; + let assistant_message_id = Some(msg.id); + + tokio::spawn(async move { + let _ = insert_message_embedding( + &state, + user.as_ref(), + AuthMethod::Jwt, + user_key.as_ref(), + &text, + conversation_id, + None, + assistant_message_id, + ) + .await; + }); + + Ok(msg) + } + + pub async fn set_assistant_reaction_for_current_user_message( + &self, + reaction: &str, + ) -> Result { + let Some(message_uuid) = self.current_user_message_uuid else { + return Err(ApiError::InternalServerError); + }; + + let mut conn = self + .state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + UserMessage::set_assistant_reaction( + &mut conn, + message_uuid, + self.user.uuid, + Some(reaction.to_string()), + ) + .map_err(|e| { + error!("Failed to set assistant reaction on user message: {e:?}"); + ApiError::InternalServerError + }) + } + + pub async fn insert_tool_call_and_output( + &self, + tool_call: &AgentToolCall, + result: &ToolResult, + ) -> Result<(ToolCall, ToolOutput), ApiError> { + let args_json = serde_json::to_string(&tool_call.args).unwrap_or_else(|_| "{}".to_string()); + let arguments_enc = Some(encrypt_with_key(&self.user_key, args_json.as_bytes()).await); + let argument_tokens = count_tokens(&args_json) as i32; + + let output_text = if result.success { + result.output.clone() + } else { + result + .error + .clone() + .unwrap_or_else(|| "Unknown error".to_string()) + }; + let output_tokens = count_tokens(&output_text) as i32; + let output_enc = encrypt_with_key(&self.user_key, output_text.as_bytes()).await; + + let mut conn = self + .state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + let call = NewToolCall { + uuid: Uuid::new_v4(), + conversation_id: self.conversation.id, + response_id: None, + user_id: self.user.uuid, + name: tool_call.name.clone(), + arguments_enc, + argument_tokens, + status: "completed".to_string(), + } + .insert(&mut conn) + .map_err(|e| { + error!("Failed to insert tool call: {e:?}"); + ApiError::InternalServerError + })?; + + let out = NewToolOutput { + uuid: Uuid::new_v4(), + conversation_id: self.conversation.id, + response_id: None, + user_id: self.user.uuid, + tool_call_fk: call.id, + output_enc, + output_tokens, + status: "completed".to_string(), + error: result.error.clone(), + } + .insert(&mut conn) + .map_err(|e| { + error!("Failed to insert tool output: {e:?}"); + ApiError::InternalServerError + })?; + + Ok((call, out)) + } + + pub(crate) async fn maybe_compact(&self) -> Result<(), ApiError> { + let mut conn = self + .state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + let latest_summary = ConversationSummary::get_latest_for_conversation( + &mut conn, + self.user.uuid, + self.conversation.id, + ) + .map_err(|_| ApiError::InternalServerError)?; + + let summary_tokens = latest_summary + .as_ref() + .map(|s| s.content_tokens) + .unwrap_or(0); + let summary_text = if let Some(s) = &latest_summary { + decrypt_string(&self.user_key, Some(&s.content_enc)) + .map_err(|_| ApiError::InternalServerError)? + .unwrap_or_default() + } else { + String::new() + }; + + let mut metadata = RawThreadMessageMetadata::get_conversation_context_metadata( + &mut conn, + self.conversation.id, + ) + .map_err(|e| { + error!("Failed to load context metadata: {e:?}"); + ApiError::InternalServerError + })?; + + if let Some(s) = &latest_summary { + metadata.retain(|m| m.created_at > s.to_created_at); + } + + metadata.retain(|m| m.message_type != "reasoning"); + + let current_tokens: i32 = summary_tokens + + metadata + .iter() + .map(|m| m.token_count.unwrap_or(0)) + .sum::(); + + if !self.compaction.should_compact( + current_tokens as usize, + DEFAULT_CONTEXT_WINDOW as usize, + DEFAULT_COMPACTION_THRESHOLD, + ) { + return Ok(()); + } + + if metadata.len() <= MIN_MESSAGES_IN_CONTEXT { + return Ok(()); + } + + // Summarize the oldest half, keep at least MIN_MESSAGES_IN_CONTEXT recent messages + let keep_count = (metadata.len() / 2).max(MIN_MESSAGES_IN_CONTEXT); + let to_summarize_count = metadata.len().saturating_sub(keep_count); + if to_summarize_count == 0 { + return Ok(()); + } + + let to_summarize: Vec<(String, i64)> = metadata + .iter() + .take(to_summarize_count) + .map(|m| (m.message_type.clone(), m.id)) + .collect(); + + let raw_messages = + RawThreadMessage::get_messages_by_ids(&mut conn, self.conversation.id, &to_summarize) + .map_err(|e| { + error!("Failed to load messages for compaction: {e:?}"); + ApiError::InternalServerError + })?; + + if raw_messages.is_empty() { + return Ok(()); + } + + let mut formatted: Vec = Vec::new(); + for m in &raw_messages { + if m.message_type == "reasoning" { + continue; + } + let role = match m.message_type.as_str() { + "user" => "user", + "assistant" => "assistant", + _ => "tool", + }; + let content = self.render_raw_message(m)?; + formatted.push(format!("[{}]: {}", role, content)); + } + + let new_messages = formatted.join("\n---\n"); + + let summary = self + .compaction + .summarize(&self.lm, &summary_text, &new_messages) + .await?; + + let content_tokens = count_tokens(&summary) as i32; + let content_enc = encrypt_with_key(&self.user_key, summary.as_bytes()).await; + + // Embed summary for conversation_search over summaries + let embedding = crate::web::get_embedding_vector( + &self.state, + self.user.as_ref(), + AuthMethod::Jwt, + crate::rag::DEFAULT_EMBEDDING_MODEL, + &summary, + Some(crate::rag::DEFAULT_EMBEDDING_DIM), + ) + .await; + + let embedding_enc = match embedding { + Ok((vec, _tok)) => { + let bytes = serialize_f32_le(&vec); + Some(encrypt_with_key(&self.user_key, &bytes).await) + } + Err(e) => { + warn!("Failed to embed summary for conversation_search: {e:?}"); + None + } + }; + + let from_created_at = raw_messages.first().unwrap().created_at; + let to_created_at = raw_messages.last().unwrap().created_at; + let previous_summary_id = latest_summary.as_ref().map(|s| s.id); + + let new_summary = NewConversationSummary { + uuid: Uuid::new_v4(), + user_id: self.user.uuid, + conversation_id: self.conversation.id, + from_created_at, + to_created_at, + message_count: raw_messages.len() as i32, + content_enc, + content_tokens, + embedding_enc, + previous_summary_id, + }; + + let _ = new_summary.insert(&mut conn).map_err(|e| { + error!("Failed to insert conversation summary: {e:?}"); + ApiError::InternalServerError + })?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::normalize_reply_reaction_for_step; + + #[test] + fn reply_reaction_is_allowed_and_normalized_on_first_step() { + assert_eq!( + normalize_reply_reaction_for_step(" ❤️ ", true, true), + Some("❤️".to_string()) + ); + } + + #[test] + fn reply_reaction_is_ignored_after_tool_results() { + assert_eq!(normalize_reply_reaction_for_step("❤️", true, false), None); + } + + #[test] + fn reply_reaction_is_ignored_without_active_user_message() { + assert_eq!(normalize_reply_reaction_for_step("❤️", false, true), None); + } +} diff --git a/src/web/agent/schedules.rs b/src/web/agent/schedules.rs new file mode 100644 index 00000000..154792c3 --- /dev/null +++ b/src/web/agent/schedules.rs @@ -0,0 +1,1824 @@ +use async_trait::async_trait; +use chrono::{DateTime, Datelike, Duration, NaiveDateTime, TimeZone, Utc}; +use chrono_tz::Tz; +use diesel::prelude::*; +use diesel::sql_query; +use diesel::sql_types::BigInt; +use futures::stream::{self, StreamExt}; +use secp256k1::SecretKey; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::oneshot; +use tokio::time::{sleep, Duration as TokioDuration}; +use tracing::{debug, error, info, trace, warn}; +use uuid::Uuid; + +use crate::encrypt::decrypt_string; +use crate::models::agent_schedule_runs::{ + AgentScheduleRun, AgentScheduleRunError, AgentScheduleRunWriteResult, NewAgentScheduleRun, + AGENT_SCHEDULE_RUN_STATUS_COMPLETED, +}; +use crate::models::agent_schedules::{ + parse_local_date, parse_local_time, AgentSchedule, AgentScheduleError, NewAgentSchedule, + ScheduleSpec, ScheduleWeekday, SCHEDULE_KIND_RECURRING, SCHEDULE_STATUS_ACTIVE, + SCHEDULE_STATUS_CANCELLED, SCHEDULE_STATUS_COMPLETED, SCHEDULE_TIMEZONE_MODE_FIXED, + SCHEDULE_TIMEZONE_MODE_FOLLOW_USER, +}; +use crate::models::agents::{Agent, AGENT_KIND_MAIN, AGENT_KIND_SUBAGENT}; +use crate::models::schema::agent_schedules; +use crate::models::user_preferences::{UserPreference, USER_PREFERENCE_TIMEZONE}; +use crate::models::users::User; +use crate::push::{enqueue_agent_message_notification, AgentPushTarget}; +use crate::web::openai::{get_chat_completion_response, BillingContext, CompletionChunk}; +use crate::web::openai_auth::AuthMethod; +use crate::AppState; + +use super::runtime; +use super::tools::{Tool, ToolResult}; + +const DEFAULT_STALE_AFTER_MINUTES: i32 = 15; +const SCHEDULE_MATERIALIZER_BATCH_SIZE: i64 = 32; +const SCHEDULE_RUN_BATCH_SIZE: i64 = 16; +const SCHEDULE_RUN_LEASE_TTL_SECONDS: i32 = 180; +const SCHEDULE_RUN_HEARTBEAT_INTERVAL_SECONDS: u64 = 30; +const SCHEDULE_WORKER_POLL_INTERVAL_SECONDS: u64 = 5; +const SCHEDULE_RUN_MAX_CONCURRENCY: usize = 4; +const SCHEDULE_RUN_MAX_ATTEMPTS: i32 = 8; +const SCHEDULE_RUN_MAX_RETRY_BACKOFF_SECONDS: i32 = 15 * 60; + +#[derive(diesel::deserialize::QueryableByName)] +struct DueScheduleIdRow { + #[diesel(sql_type = diesel::sql_types::BigInt)] + id: i64, +} + +#[derive(Debug, Clone)] +struct PersistedScheduledMessage { + message_id: Uuid, + text: String, +} + +#[derive(Debug, Clone)] +struct ScheduledTurnOutcome { + persisted_messages: Vec, + had_error: bool, +} + +pub(crate) fn start_schedule_worker(state: Arc) { + info!( + "starting schedule worker (poll_interval={}s, materializer_batch_size={}, run_batch_size={}, lease_ttl={}s)", + SCHEDULE_WORKER_POLL_INTERVAL_SECONDS, + SCHEDULE_MATERIALIZER_BATCH_SIZE, + SCHEDULE_RUN_BATCH_SIZE, + SCHEDULE_RUN_LEASE_TTL_SECONDS, + ); + + tokio::spawn(async move { + loop { + if let Err(e) = materialize_due_schedules(&state).await { + error!("schedule materialization failed: {e}"); + } + + if let Err(e) = process_schedule_run_batch(&state).await { + error!("schedule run batch failed: {e}"); + } + + sleep(TokioDuration::from_secs( + SCHEDULE_WORKER_POLL_INTERVAL_SECONDS, + )) + .await; + } + }); +} + +pub(crate) fn refresh_follow_user_schedules_for_user( + conn: &mut PgConnection, + user_id: Uuid, + timezone_name: &str, +) -> Result<(), AgentScheduleError> { + let timezone = parse_timezone(timezone_name)?; + let schedules = AgentSchedule::list_active_follow_user_for_user(conn, user_id)?; + + if !schedules.is_empty() { + info!( + "refreshing {} follow-user schedule(s) for user {} to timezone {}", + schedules.len(), + user_id, + timezone.name(), + ); + } + + let now = Utc::now(); + + for schedule in schedules { + let spec = schedule.spec()?; + let next_scheduled_for = + recompute_next_due_for_timezone_change(&schedule, &spec, timezone, now)?; + + diesel::update(agent_schedules::table.filter(agent_schedules::id.eq(schedule.id))) + .set(( + agent_schedules::resolved_timezone.eq(timezone.name()), + agent_schedules::next_scheduled_for.eq(next_scheduled_for), + agent_schedules::updated_at.eq(diesel::dsl::now), + )) + .execute(conn) + .map_err(AgentScheduleError::DatabaseError)?; + + trace!( + "refreshed follow-user schedule {} for user {} (next_scheduled_for={:?})", + schedule.uuid, + user_id, + next_scheduled_for, + ); + } + + Ok(()) +} + +pub struct ScheduleTaskTool { + state: Arc, + user: Arc, + user_key: Arc, + agent: Agent, +} + +impl ScheduleTaskTool { + pub fn new( + state: Arc, + user: Arc, + user_key: Arc, + agent: Agent, + ) -> Self { + Self { + state, + user, + user_key, + agent, + } + } +} + +#[async_trait] +impl Tool for ScheduleTaskTool { + fn name(&self) -> &str { + "schedule_task" + } + + fn description(&self) -> &str { + "Schedule a future wakeup for yourself. Use structured arguments for one-off or recurring schedules. The instruction should be written for your future self, not as final notification copy." + } + + fn args_schema(&self) -> &str { + r#"{ + "schedule_kind": "one_off|recurring", + "instruction": "future-agent instruction in natural language", + "description": "optional short operational summary", + "timezone_mode": "optional: follow_user|fixed (default follow_user)", + "fixed_timezone": "optional IANA timezone when timezone_mode=fixed", + "stale_after_minutes": "optional positive integer (default 15)", + "local_date": "required for one_off: YYYY-MM-DD", + "local_time": "required for one_off/daily/weekly: HH:MM 24-hour", + "recurrence_type": "required for recurring: interval|daily|weekly", + "every_n": "required for interval: positive integer", + "interval_unit": "required for interval: hours", + "weekdays": "required for weekly: comma-separated weekdays like monday,friday or monday,tuesday,wednesday,thursday,friday" +}"# + } + + async fn execute(&self, args: &HashMap) -> ToolResult { + let Some(schedule_kind) = args.get("schedule_kind").map(|s| s.trim()) else { + return ToolResult::error("'schedule_kind' argument required"); + }; + let Some(instruction) = args.get("instruction").map(|s| s.trim()) else { + return ToolResult::error("'instruction' argument required"); + }; + + if instruction.is_empty() { + return ToolResult::error("'instruction' must not be empty"); + } + + let spec = match parse_schedule_spec_from_args(args) { + Ok(spec) => spec, + Err(e) => return ToolResult::error(e), + }; + + let timezone_mode = args + .get("timezone_mode") + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .unwrap_or(SCHEDULE_TIMEZONE_MODE_FOLLOW_USER); + + let fixed_timezone = args + .get("fixed_timezone") + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(str::to_string); + + if timezone_mode != SCHEDULE_TIMEZONE_MODE_FOLLOW_USER + && timezone_mode != SCHEDULE_TIMEZONE_MODE_FIXED + { + return ToolResult::error( + "'timezone_mode' must be 'follow_user' or 'fixed' if provided", + ); + } + + if timezone_mode == SCHEDULE_TIMEZONE_MODE_FIXED && fixed_timezone.is_none() { + return ToolResult::error("'fixed_timezone' is required when timezone_mode='fixed'"); + } + + if schedule_kind == "one_off" && spec.schedule_kind() != "one_off" { + return ToolResult::error("one_off schedules require local_date and local_time"); + } + + if schedule_kind == "recurring" && spec.schedule_kind() != SCHEDULE_KIND_RECURRING { + return ToolResult::error( + "recurring schedules require recurrence_type and recurrence parameters", + ); + } + + let stale_after_minutes = match args + .get("stale_after_minutes") + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + { + Some(value) => match value.parse::() { + Ok(value) if value > 0 => value, + _ => { + return ToolResult::error( + "'stale_after_minutes' must be a positive integer when provided", + ) + } + }, + None => DEFAULT_STALE_AFTER_MINUTES, + }; + + let resolved_timezone = match resolve_initial_timezone_name( + &self.state, + self.user.as_ref(), + self.user_key.as_ref(), + timezone_mode, + fixed_timezone.as_deref(), + ) + .await + { + Ok(timezone) => timezone, + Err(e) => { + warn!( + "schedule_task could not resolve timezone for user {} agent {} (timezone_mode={}, fixed_timezone={:?}): {}", + self.user.uuid, + self.agent.uuid, + timezone_mode, + fixed_timezone, + e, + ); + return ToolResult::error(e); + } + }; + + let tz = match parse_timezone(&resolved_timezone) { + Ok(timezone) => timezone, + Err(e) => return ToolResult::error(e.to_string()), + }; + let now = Utc::now(); + let next_scheduled_for = match compute_initial_next_due(&spec, tz, now) { + Ok(next) => next, + Err(e) => return ToolResult::error(e.to_string()), + }; + + if next_scheduled_for <= now { + let requested_local = next_scheduled_for.with_timezone(&tz); + let now_local = now.with_timezone(&tz); + + warn!( + "schedule_task rejected past time for user {} agent {} (timezone={}, requested_local={}, now_local={}, schedule_kind={})", + self.user.uuid, + self.agent.uuid, + resolved_timezone, + requested_local.format("%Y-%m-%d %H:%M:%S"), + now_local.format("%Y-%m-%d %H:%M:%S"), + schedule_kind, + ); + + return ToolResult::error(format!( + "Scheduled time must be in the future in {}. Requested {}, but current time there is {}. Check local_date/local_time and timezone settings.", + resolved_timezone, + requested_local.format("%Y-%m-%d %H:%M:%S"), + now_local.format("%Y-%m-%d %H:%M:%S"), + )); + } + + let description = args + .get("description") + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| truncate_for_description(instruction)); + + let instruction_enc = + crate::encrypt::encrypt_with_key(self.user_key.as_ref(), instruction.as_bytes()).await; + let schedule_spec = match spec.to_value() { + Ok(value) => value, + Err(e) => return ToolResult::error(e.to_string()), + }; + + let new_schedule = NewAgentSchedule { + uuid: Uuid::new_v4(), + user_id: self.user.uuid, + agent_id: self.agent.id, + description, + instruction_enc, + schedule_kind: spec.schedule_kind().to_string(), + recurrence_type: spec.recurrence_type().map(str::to_string), + schedule_spec, + timezone_mode: timezone_mode.to_string(), + resolved_timezone: resolved_timezone.clone(), + fixed_timezone, + stale_after_minutes, + status: SCHEDULE_STATUS_ACTIVE.to_string(), + next_scheduled_for: Some(next_scheduled_for), + last_scheduled_for: None, + last_run_at: None, + run_count: 0, + cancelled_at: None, + }; + + let mut conn = match self.state.db.get_pool().get() { + Ok(conn) => conn, + Err(_) => return ToolResult::error("Database connection error"), + }; + + match new_schedule.insert(&mut conn) { + Ok(schedule) => { + let spec_summary = spec.summary(); + info!( + "created schedule {} for user {} agent {} (kind={}, recurrence={:?}, timezone_mode={}, resolved_timezone={}, next_scheduled_for={}, stale_after_minutes={})", + schedule.uuid, + self.user.uuid, + self.agent.uuid, + schedule.schedule_kind, + schedule.recurrence_type, + schedule.timezone_mode, + schedule.resolved_timezone, + next_scheduled_for.format("%Y-%m-%d %H:%M:%S UTC"), + stale_after_minutes, + ); + + ToolResult::success(format!( + "Created schedule {} ({}) in timezone {}. Next run: {} UTC.", + schedule.uuid, + spec_summary, + resolved_timezone, + next_scheduled_for.format("%Y-%m-%d %H:%M:%S") + )) + } + Err(e) => { + error!("schedule_task insert failed: {e:?}"); + ToolResult::error("Failed to create schedule") + } + } + } +} + +pub struct ListSchedulesTool { + state: Arc, + user: Arc, + agent: Agent, +} + +impl ListSchedulesTool { + pub fn new(state: Arc, user: Arc, agent: Agent) -> Self { + Self { state, user, agent } + } +} + +#[async_trait] +impl Tool for ListSchedulesTool { + fn name(&self) -> &str { + "list_schedules" + } + + fn description(&self) -> &str { + "List schedules for this agent. Defaults to active schedules only." + } + + fn args_schema(&self) -> &str { + r#"{"status": "optional: active|completed|cancelled|all (default active)"}"# + } + + async fn execute(&self, args: &HashMap) -> ToolResult { + let status_filter = match args.get("status").map(|s| s.trim()) { + Some("all") => None, + None | Some("") => Some(SCHEDULE_STATUS_ACTIVE), + Some(SCHEDULE_STATUS_ACTIVE) => Some(SCHEDULE_STATUS_ACTIVE), + Some(SCHEDULE_STATUS_COMPLETED) => Some(SCHEDULE_STATUS_COMPLETED), + Some(SCHEDULE_STATUS_CANCELLED) => Some(SCHEDULE_STATUS_CANCELLED), + Some(_) => { + return ToolResult::error( + "'status' must be one of active, completed, cancelled, or all", + ) + } + }; + + let mut conn = match self.state.db.get_pool().get() { + Ok(conn) => conn, + Err(_) => return ToolResult::error("Database connection error"), + }; + + match AgentSchedule::list_by_agent(&mut conn, self.user.uuid, self.agent.id, status_filter) + { + Ok(schedules) => { + debug!( + "listed {} schedule(s) for user {} agent {} (status_filter={:?})", + schedules.len(), + self.user.uuid, + self.agent.uuid, + status_filter, + ); + + if schedules.is_empty() { + return ToolResult::success("No schedules found.".to_string()); + } + + let mut output = format!("Found {} schedule(s):\n\n", schedules.len()); + for schedule in schedules { + let spec_summary = schedule + .spec() + .map(|spec| spec.summary()) + .unwrap_or_else(|_| "[invalid spec]".to_string()); + let next_text = schedule + .next_scheduled_for + .map(|dt| { + let tz = parse_timezone_or_utc(&schedule.resolved_timezone); + format_local_time(dt, tz) + }) + .unwrap_or_else(|| "none".to_string()); + output.push_str(&format!( + "- id: {}\n status: {}\n description: {}\n rule: {}\n timezone: {} ({})\n next: {}\n\n", + schedule.uuid, + schedule.status, + schedule.description.trim(), + spec_summary, + schedule.timezone_mode, + schedule.resolved_timezone, + next_text, + )); + } + + ToolResult::success(output) + } + Err(e) => { + error!("list_schedules failed: {e:?}"); + ToolResult::error("Failed to list schedules") + } + } + } +} + +pub struct CancelScheduleTool { + state: Arc, + user: Arc, + agent: Agent, +} + +impl CancelScheduleTool { + pub fn new(state: Arc, user: Arc, agent: Agent) -> Self { + Self { state, user, agent } + } +} + +#[async_trait] +impl Tool for CancelScheduleTool { + fn name(&self) -> &str { + "cancel_schedule" + } + + fn description(&self) -> &str { + "Cancel an existing schedule for this agent by schedule id (UUID). Prevents future runs and cancels unstarted pending runs." + } + + fn args_schema(&self) -> &str { + r#"{"schedule_id": "schedule UUID from list_schedules"}"# + } + + async fn execute(&self, args: &HashMap) -> ToolResult { + let Some(schedule_id) = args.get("schedule_id").map(|s| s.trim()) else { + return ToolResult::error("'schedule_id' argument required"); + }; + + let schedule_uuid = match Uuid::parse_str(schedule_id) { + Ok(uuid) => uuid, + Err(_) => return ToolResult::error("'schedule_id' must be a valid UUID"), + }; + + let mut conn = match self.state.db.get_pool().get() { + Ok(conn) => conn, + Err(_) => return ToolResult::error("Database connection error"), + }; + + let result = conn.transaction::(|conn| { + let Some(schedule) = AgentSchedule::get_by_uuid_and_agent( + conn, + schedule_uuid, + self.user.uuid, + self.agent.id, + )? + else { + debug!( + "cancel_schedule did not find schedule {} for user {} agent {}", + schedule_uuid, self.user.uuid, self.agent.uuid, + ); + return Ok("Schedule not found.".to_string()); + }; + + if schedule.status == SCHEDULE_STATUS_CANCELLED { + debug!( + "schedule {} already cancelled for user {} agent {}", + schedule.uuid, self.user.uuid, self.agent.uuid, + ); + return Ok(format!("Schedule {} is already cancelled.", schedule.uuid)); + } + + diesel::update(agent_schedules::table.filter(agent_schedules::id.eq(schedule.id))) + .set(( + agent_schedules::status.eq(SCHEDULE_STATUS_CANCELLED), + agent_schedules::next_scheduled_for.eq::>>(None), + agent_schedules::cancelled_at.eq(diesel::dsl::now), + agent_schedules::updated_at.eq(diesel::dsl::now), + )) + .execute(conn) + .map_err(AgentScheduleError::DatabaseError)?; + + let cancelled_runs = AgentScheduleRun::cancel_unstarted_for_schedule(conn, schedule.id) + .map_err(|e| match e { + AgentScheduleRunError::DatabaseError(err) => { + AgentScheduleError::DatabaseError(err) + } + })?; + + info!( + "cancelled schedule {} for user {} agent {} (cancelled_runs={})", + schedule.uuid, self.user.uuid, self.agent.uuid, cancelled_runs, + ); + + Ok(format!( + "Cancelled schedule {} and {} unstarted run(s).", + schedule.uuid, cancelled_runs + )) + }); + + match result { + Ok(msg) => ToolResult::success(msg), + Err(e) => { + error!("cancel_schedule failed: {e:?}"); + ToolResult::error("Failed to cancel schedule") + } + } + } +} + +async fn materialize_due_schedules(state: &Arc) -> Result<(), String> { + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| "database connection error".to_string())?; + + conn.transaction::<(), AgentScheduleError, _>(|conn| { + let rows = sql_query( + r#" + SELECT s.id + FROM agent_schedules s + WHERE s.status = 'active' + AND s.next_scheduled_for IS NOT NULL + AND s.next_scheduled_for <= NOW() + ORDER BY s.next_scheduled_for ASC, s.id ASC + FOR UPDATE OF s SKIP LOCKED + LIMIT $1 + "#, + ) + .bind::(SCHEDULE_MATERIALIZER_BATCH_SIZE) + .load::(conn) + .map_err(AgentScheduleError::DatabaseError)?; + + if !rows.is_empty() { + debug!("materializing {} due schedule(s)", rows.len()); + } + + for row in rows { + let Some(schedule) = AgentSchedule::get_by_id(conn, row.id)? else { + continue; + }; + + if schedule.status != SCHEDULE_STATUS_ACTIVE { + continue; + } + + let Some(current_due) = schedule.next_scheduled_for else { + continue; + }; + + let spec = schedule.spec()?; + let tz = parse_timezone_or_utc(&schedule.resolved_timezone); + let next_scheduled_for = compute_following_next_due(&spec, tz, current_due)?; + + let stale_after_at = + current_due + Duration::minutes(schedule.stale_after_minutes as i64); + + let new_run = NewAgentScheduleRun { + uuid: Uuid::new_v4(), + schedule_id: schedule.id, + user_id: schedule.user_id, + agent_id: schedule.agent_id, + scheduled_for: current_due, + stale_after_at, + status: crate::models::agent_schedule_runs::AGENT_SCHEDULE_RUN_STATUS_PENDING + .to_string(), + next_attempt_at: current_due, + }; + + let inserted_run = new_run.insert(conn).map_err(|e| match e { + AgentScheduleRunError::DatabaseError(err) => AgentScheduleError::DatabaseError(err), + })?; + + let new_status = if spec.schedule_kind() == "one_off" { + SCHEDULE_STATUS_COMPLETED + } else { + SCHEDULE_STATUS_ACTIVE + }; + + diesel::update(agent_schedules::table.filter(agent_schedules::id.eq(schedule.id))) + .set(( + agent_schedules::status.eq(new_status), + agent_schedules::next_scheduled_for.eq(next_scheduled_for), + agent_schedules::last_scheduled_for.eq(Some(current_due)), + agent_schedules::updated_at.eq(diesel::dsl::now), + )) + .execute(conn) + .map_err(AgentScheduleError::DatabaseError)?; + + debug!( + "materialized schedule {} for user {} into run {} (scheduled_for={}, next_scheduled_for={:?})", + schedule.uuid, + schedule.user_id, + inserted_run.uuid, + current_due.format("%Y-%m-%d %H:%M:%S UTC"), + next_scheduled_for, + ); + } + + Ok(()) + }) + .map_err(|e| e.to_string())?; + + Ok(()) +} + +async fn process_schedule_run_batch(state: &Arc) -> Result<(), String> { + let lease_owner = format!("schedule-worker:{}:{}", std::process::id(), Uuid::new_v4()); + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| "database connection error".to_string())?; + + let runs = AgentScheduleRun::lease_pending( + &mut conn, + SCHEDULE_RUN_BATCH_SIZE, + &lease_owner, + SCHEDULE_RUN_LEASE_TTL_SECONDS, + ) + .map_err(|e| e.to_string())?; + drop(conn); + + if runs.is_empty() { + return Ok(()); + } + + debug!( + "leased {} scheduled run(s) (lease_owner={})", + runs.len(), + lease_owner, + ); + + stream::iter(runs) + .for_each_concurrent(SCHEDULE_RUN_MAX_CONCURRENCY, |run| { + let state = state.clone(); + let lease_owner = lease_owner.clone(); + async move { + if let Err(e) = process_leased_schedule_run(&state, run, &lease_owner).await { + error!("scheduled run processing failed: {}", e); + } + } + }) + .await; + + Ok(()) +} + +async fn process_leased_schedule_run( + state: &Arc, + run: AgentScheduleRun, + lease_owner: &str, +) -> Result<(), String> { + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| "database connection error".to_string())?; + + if run.stale_after_at <= Utc::now() { + info!( + "expiring stale scheduled run {} for user {} (schedule_id={}, scheduled_for={}, stale_after_at={})", + run.uuid, + run.user_id, + run.schedule_id, + run.scheduled_for.format("%Y-%m-%d %H:%M:%S UTC"), + run.stale_after_at.format("%Y-%m-%d %H:%M:%S UTC"), + ); + + record_run_transition( + AgentScheduleRun::mark_expired( + &mut conn, + run.id, + lease_owner, + Some("schedule occurrence expired before execution"), + ) + .map_err(|e| e.to_string())?, + run.id, + lease_owner, + "expired", + ); + update_schedule_after_terminal_run(&mut conn, run.schedule_id) + .map_err(|e| e.to_string())?; + return Ok(()); + } + + if run.first_output_at.is_some() || run.output_count > 0 { + info!( + "marking scheduled run {} complete after partial-output recovery (user={}, schedule_id={}, output_count={})", + run.uuid, + run.user_id, + run.schedule_id, + run.output_count, + ); + + record_run_transition( + AgentScheduleRun::mark_completed( + &mut conn, + run.id, + lease_owner, + false, + Some("marking completed after prior partial output recovery"), + ) + .map_err(|e| e.to_string())?, + run.id, + lease_owner, + AGENT_SCHEDULE_RUN_STATUS_COMPLETED, + ); + update_schedule_after_terminal_run(&mut conn, run.schedule_id) + .map_err(|e| e.to_string())?; + return Ok(()); + } + + let Some(schedule) = + AgentSchedule::get_by_id(&mut conn, run.schedule_id).map_err(|e| e.to_string())? + else { + warn!( + "scheduled run {} could not find schedule definition {}", + run.uuid, run.schedule_id, + ); + + record_run_transition( + AgentScheduleRun::mark_failed( + &mut conn, + run.id, + lease_owner, + Some("schedule definition missing"), + ) + .map_err(|e| e.to_string())?, + run.id, + lease_owner, + "failed", + ); + return Ok(()); + }; + + if schedule.status == SCHEDULE_STATUS_CANCELLED { + info!( + "scheduled run {} skipped because schedule {} is cancelled", + run.uuid, schedule.uuid, + ); + + record_run_transition( + AgentScheduleRun::mark_cancelled( + &mut conn, + run.id, + lease_owner, + Some("schedule cancelled"), + ) + .map_err(|e| e.to_string())?, + run.id, + lease_owner, + "cancelled", + ); + return Ok(()); + } + + let Some(agent) = Agent::get_by_id(&mut conn, run.agent_id).map_err(|e| e.to_string())? else { + warn!( + "scheduled run {} could not find agent {} for schedule {}", + run.uuid, run.agent_id, schedule.uuid, + ); + + record_run_transition( + AgentScheduleRun::mark_failed(&mut conn, run.id, lease_owner, Some("agent missing")) + .map_err(|e| e.to_string())?, + run.id, + lease_owner, + "failed", + ); + return Ok(()); + }; + + let Some(user) = User::get_by_uuid(&mut conn, run.user_id).map_err(|e| e.to_string())? else { + warn!( + "scheduled run {} could not find user {} for schedule {}", + run.uuid, run.user_id, schedule.uuid, + ); + + record_run_transition( + AgentScheduleRun::mark_failed(&mut conn, run.id, lease_owner, Some("user missing")) + .map_err(|e| e.to_string())?, + run.id, + lease_owner, + "failed", + ); + return Ok(()); + }; + drop(conn); + + info!( + "starting scheduled run {} for schedule {} user {} agent {} (attempt={}, scheduled_for={})", + run.uuid, + schedule.uuid, + user.uuid, + agent.uuid, + run.attempt_count + 1, + run.scheduled_for.format("%Y-%m-%d %H:%M:%S UTC"), + ); + + let user_key = state + .get_user_key(user.uuid, None, None) + .await + .map_err(|e| format!("failed to derive user key: {e:?}"))?; + let instruction = decrypt_string(&user_key, Some(&schedule.instruction_enc)) + .map_err(|e| format!("failed to decrypt schedule instruction: {e:?}"))? + .unwrap_or_default(); + + let input = build_scheduled_turn_input(&schedule, &run, &instruction); + + let (stop_tx, stop_rx) = oneshot::channel(); + let heartbeat_state = state.clone(); + let heartbeat_run_id = run.id; + let heartbeat_owner = lease_owner.to_string(); + let heartbeat_handle = tokio::spawn(async move { + heartbeat_schedule_run_lease(heartbeat_state, heartbeat_run_id, heartbeat_owner, stop_rx) + .await; + }); + + let turn_result = run_scheduled_agent_turn( + state, + user.clone(), + user_key, + &agent, + &run, + lease_owner, + &input, + ) + .await; + + let _ = stop_tx.send(()); + let _ = heartbeat_handle.await; + + match turn_result { + Ok(outcome) => { + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| "database connection error".to_string())?; + + let push_enqueued = if !outcome.persisted_messages.is_empty() { + let preview = + compose_scheduled_preview(state, &user, &outcome.persisted_messages).await; + let target = agent_push_target(&agent); + let first_message = &outcome.persisted_messages[0]; + match enqueue_agent_message_notification( + state, + &user, + target, + first_message.message_id, + &preview, + ) + .await + { + Ok(Some(_)) => true, + Ok(None) => false, + Err(e) => { + error!("failed to enqueue scheduled agent push: {e:?}"); + false + } + } + } else { + false + }; + + let terminal_error = if outcome.had_error && !outcome.persisted_messages.is_empty() { + Some("scheduled turn ended after partial output; preserving existing output") + } else { + None + }; + + let message_count = outcome.persisted_messages.len(); + + if outcome.had_error && outcome.persisted_messages.is_empty() { + if run.attempt_count + 1 >= SCHEDULE_RUN_MAX_ATTEMPTS { + error!( + "scheduled run {} for schedule {} user {} failed permanently before producing output (attempt={})", + run.uuid, + schedule.uuid, + user.uuid, + run.attempt_count + 1, + ); + + record_run_transition( + AgentScheduleRun::mark_failed( + &mut conn, + run.id, + lease_owner, + Some("scheduled turn failed before producing output"), + ) + .map_err(|e| e.to_string())?, + run.id, + lease_owner, + "failed", + ); + } else { + let retry_after_seconds = retry_backoff_seconds(run.attempt_count + 1); + warn!( + "retrying scheduled run {} for schedule {} user {} after {}s (attempt={})", + run.uuid, + schedule.uuid, + user.uuid, + retry_after_seconds, + run.attempt_count + 1, + ); + + record_run_transition( + AgentScheduleRun::mark_retry( + &mut conn, + run.id, + lease_owner, + Some("scheduled turn failed before producing output"), + retry_after_seconds, + ) + .map_err(|e| e.to_string())?, + run.id, + lease_owner, + "retry", + ); + } + } else { + record_run_transition( + AgentScheduleRun::mark_completed( + &mut conn, + run.id, + lease_owner, + push_enqueued, + terminal_error, + ) + .map_err(|e| e.to_string())?, + run.id, + lease_owner, + AGENT_SCHEDULE_RUN_STATUS_COMPLETED, + ); + update_schedule_after_terminal_run(&mut conn, run.schedule_id) + .map_err(|e| e.to_string())?; + + info!( + "completed scheduled run {} for schedule {} user {} agent {} (messages={}, push_enqueued={}, had_error={})", + run.uuid, + schedule.uuid, + user.uuid, + agent.uuid, + message_count, + push_enqueued, + outcome.had_error, + ); + } + } + Err(e) => { + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| "database connection error".to_string())?; + if run.attempt_count + 1 >= SCHEDULE_RUN_MAX_ATTEMPTS { + error!( + "scheduled run {} for user {} failed permanently after execution error (schedule_id={}, attempt={})", + run.uuid, + run.user_id, + run.schedule_id, + run.attempt_count + 1, + ); + + record_run_transition( + AgentScheduleRun::mark_failed(&mut conn, run.id, lease_owner, Some(&e)) + .map_err(|err| err.to_string())?, + run.id, + lease_owner, + "failed", + ); + } else { + let retry_after_seconds = retry_backoff_seconds(run.attempt_count + 1); + warn!( + "retrying scheduled run {} for user {} after execution error in {}s (schedule_id={}, attempt={})", + run.uuid, + run.user_id, + retry_after_seconds, + run.schedule_id, + run.attempt_count + 1, + ); + + record_run_transition( + AgentScheduleRun::mark_retry( + &mut conn, + run.id, + lease_owner, + Some(&e), + retry_after_seconds, + ) + .map_err(|err| err.to_string())?, + run.id, + lease_owner, + "retry", + ); + } + } + } + + Ok(()) +} + +async fn run_scheduled_agent_turn( + state: &Arc, + user: User, + user_key: SecretKey, + agent: &Agent, + run: &AgentScheduleRun, + lease_owner: &str, + input: &str, +) -> Result { + let runtime_result = if agent.kind == AGENT_KIND_MAIN { + runtime::AgentRuntime::new_main(state.clone(), user.clone(), user_key).await + } else if agent.kind == AGENT_KIND_SUBAGENT { + runtime::AgentRuntime::new_subagent(state.clone(), user.clone(), user_key, agent.uuid).await + } else { + return Err(format!( + "unsupported agent kind for schedule: {}", + agent.kind + )); + }; + + let mut runtime = + runtime_result.map_err(|e| format!("failed to initialize agent runtime: {e:?}"))?; + runtime.clear_tool_results(); + runtime + .maybe_compact() + .await + .map_err(|e| format!("scheduled compaction failed: {e:?}"))?; + + let max_steps = runtime.max_steps(); + let mut persisted_messages = Vec::new(); + let mut had_error = false; + + debug!( + "running scheduled agent turn for run {} user {} agent {} (kind={}, max_steps={})", + run.uuid, user.uuid, agent.uuid, agent.kind, max_steps, + ); + + 'steps: for step_num in 0..max_steps { + match runtime.step(input, step_num == 0).await { + Ok(result) => { + trace!( + "scheduled run {} step {} produced {} tool call(s), {} message(s), done={}", + run.uuid, + step_num, + result.executed_tools.len(), + result.messages.len(), + result.done, + ); + + for executed in &result.executed_tools { + if let Err(e) = runtime + .insert_tool_call_and_output(&executed.tool_call, &executed.result) + .await + { + error!("failed to persist scheduled tool call: {e:?}"); + } + } + + for msg in result.messages { + match runtime.insert_assistant_message(&msg).await { + Ok(inserted) => { + persisted_messages.push(PersistedScheduledMessage { + message_id: inserted.uuid, + text: msg.clone(), + }); + + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| "database connection error".to_string())?; + let write_result = AgentScheduleRun::record_output( + &mut conn, + run.id, + lease_owner, + inserted.uuid, + ) + .map_err(|e| e.to_string())?; + + if !write_result.was_applied() { + return Err( + "lost lease while recording scheduled output".to_string() + ); + } + } + Err(e) => { + error!("failed to persist scheduled assistant message: {e:?}"); + had_error = true; + break 'steps; + } + } + } + + if result.done { + break 'steps; + } + } + Err(e) => { + error!("scheduled agent step failed: {e:?}"); + had_error = true; + break 'steps; + } + } + } + + Ok(ScheduledTurnOutcome { + persisted_messages, + had_error, + }) +} + +async fn heartbeat_schedule_run_lease( + state: Arc, + run_id: i64, + lease_owner: String, + mut stop_rx: oneshot::Receiver<()>, +) { + let mut interval = tokio::time::interval(TokioDuration::from_secs( + SCHEDULE_RUN_HEARTBEAT_INTERVAL_SECONDS, + )); + + loop { + tokio::select! { + _ = interval.tick() => { + let mut conn = match state.db.get_pool().get() { + Ok(conn) => conn, + Err(_) => { + warn!("stopping schedule heartbeat for run {} due to database connection error", run_id); + break; + } + }; + + match AgentScheduleRun::renew_lease( + &mut conn, + run_id, + &lease_owner, + SCHEDULE_RUN_LEASE_TTL_SECONDS, + ) { + Ok(result) if result.was_applied() => {} + Ok(_) => { + warn!("stopping schedule heartbeat for run {} because lease renewal was not applied", run_id); + break; + } + Err(e) => { + warn!("stopping schedule heartbeat for run {} because lease renewal failed: {e:?}", run_id); + break; + } + } + } + _ = &mut stop_rx => break, + } + } +} + +async fn compose_scheduled_preview( + state: &Arc, + user: &User, + messages: &[PersistedScheduledMessage], +) -> String { + let Some(first_message) = messages.first() else { + return String::new(); + }; + + trace!( + "composing scheduled preview for user {} from {} message(s)", + user.uuid, + messages.len(), + ); + + let joined_messages = messages + .iter() + .enumerate() + .map(|(index, message)| format!("{}. {}", index + 1, message.text.trim())) + .collect::>() + .join("\n\n"); + + let request = serde_json::json!({ + "model": "llama-3.3-70b", + "messages": [ + { + "role": "system", + "content": "You compress assistant messages into a short encrypted notification preview. Preserve tone and intent. Do not add facts. Return only 1-2 short sentences, ideally under 160 characters." + }, + { + "role": "user", + "content": format!("Create a notification preview from these assistant messages:\n\n{}", joined_messages) + } + ], + "temperature": 0.2, + "max_tokens": 80, + "stream": false + }); + + let headers = axum::http::HeaderMap::new(); + let billing_context = BillingContext::new(AuthMethod::Jwt, "llama-3.3-70b".to_string()); + + match get_chat_completion_response(state, user, request, &headers, billing_context).await { + Ok(mut completion) => match completion.stream.recv().await { + Some(CompletionChunk::FullResponse(response_json)) => response_json + .get("choices") + .and_then(|choices| choices.get(0)) + .and_then(|choice| choice.get("message")) + .and_then(|message| message.get("content")) + .and_then(|content| content.as_str()) + .map(|content| content.trim().to_string()) + .filter(|content| !content.is_empty()) + .unwrap_or_else(|| { + trace!( + "scheduled preview composer returned empty content for user {}, using first assistant message", + user.uuid, + ); + first_message.text.clone() + }), + _ => { + trace!( + "scheduled preview composer returned no full response for user {}, using first assistant message", + user.uuid, + ); + first_message.text.clone() + } + }, + Err(e) => { + warn!("scheduled preview composition failed, falling back to first message: {e:?}"); + first_message.text.clone() + } + } +} + +fn build_scheduled_turn_input( + schedule: &AgentSchedule, + run: &AgentScheduleRun, + instruction: &str, +) -> String { + format!( + "[SCHEDULED EVENT]\nThis is an internal scheduled wakeup, not a new live user message.\n\nSchedule ID: {}\nRun ID: {}\nDescription: {}\nScheduled for (UTC): {}\nCurrent resolved timezone: {}\nInstruction for your future self:\n{}\n\nDecide what, if anything, the user should see right now. You may send messages, call tools, or do nothing if the event is no longer relevant.", + schedule.uuid, + run.uuid, + schedule.description.trim(), + run.scheduled_for.format("%Y-%m-%d %H:%M:%S UTC"), + schedule.resolved_timezone, + instruction.trim() + ) +} + +fn parse_schedule_spec_from_args(args: &HashMap) -> Result { + let schedule_kind = args + .get("schedule_kind") + .map(|s| s.trim()) + .ok_or_else(|| "'schedule_kind' argument required".to_string())?; + + let spec = match schedule_kind { + "one_off" => { + let local_date = args + .get("local_date") + .map(|s| s.trim()) + .ok_or_else(|| "'local_date' is required for one_off schedules".to_string())?; + let local_time = args + .get("local_time") + .map(|s| s.trim()) + .ok_or_else(|| "'local_time' is required for one_off schedules".to_string())?; + + ScheduleSpec::OneOff { + local_date: local_date.to_string(), + local_time: local_time.to_string(), + } + } + "recurring" => { + let recurrence_type = + args.get("recurrence_type") + .map(|s| s.trim()) + .ok_or_else(|| { + "'recurrence_type' is required for recurring schedules".to_string() + })?; + + match recurrence_type { + "interval" => { + let every_n = args + .get("every_n") + .map(|s| s.trim()) + .ok_or_else(|| "'every_n' is required for interval schedules".to_string())? + .parse::() + .map_err(|_| "'every_n' must be a positive integer".to_string())?; + let interval_unit = args + .get("interval_unit") + .map(|s| s.trim()) + .unwrap_or("hours"); + + if interval_unit != "hours" { + return Err( + "v1 interval schedules currently support interval_unit='hours' only" + .to_string(), + ); + } + + ScheduleSpec::Interval { + every_n, + interval_unit: crate::models::agent_schedules::ScheduleIntervalUnit::Hours, + } + } + "daily" => { + let local_time = args.get("local_time").map(|s| s.trim()).ok_or_else(|| { + "'local_time' is required for daily schedules".to_string() + })?; + ScheduleSpec::Daily { + local_time: local_time.to_string(), + } + } + "weekly" => { + let local_time = args.get("local_time").map(|s| s.trim()).ok_or_else(|| { + "'local_time' is required for weekly schedules".to_string() + })?; + let weekdays_value = args + .get("weekdays") + .map(|s| s.trim()) + .ok_or_else(|| "'weekdays' is required for weekly schedules".to_string())?; + let weekdays = parse_weekdays_csv(weekdays_value)?; + ScheduleSpec::Weekly { + local_time: local_time.to_string(), + weekdays, + } + } + _ => return Err( + "'recurrence_type' must be interval, daily, or weekly for recurring schedules" + .to_string(), + ), + } + } + _ => return Err("'schedule_kind' must be 'one_off' or 'recurring'".to_string()), + }; + + spec.validate().map_err(|e| e.to_string())?; + Ok(spec) +} + +fn parse_weekdays_csv(value: &str) -> Result, String> { + let mut weekdays = Vec::new(); + + for raw in value.split(',') { + let normalized = raw.trim().to_lowercase(); + if normalized.is_empty() { + continue; + } + + let weekday = match normalized.as_str() { + "weekdays" => { + return Ok(vec![ + ScheduleWeekday::Monday, + ScheduleWeekday::Tuesday, + ScheduleWeekday::Wednesday, + ScheduleWeekday::Thursday, + ScheduleWeekday::Friday, + ]) + } + "monday" | "mon" => ScheduleWeekday::Monday, + "tuesday" | "tue" | "tues" => ScheduleWeekday::Tuesday, + "wednesday" | "wed" => ScheduleWeekday::Wednesday, + "thursday" | "thu" | "thurs" => ScheduleWeekday::Thursday, + "friday" | "fri" => ScheduleWeekday::Friday, + "saturday" | "sat" => ScheduleWeekday::Saturday, + "sunday" | "sun" => ScheduleWeekday::Sunday, + _ => return Err(format!("invalid weekday '{raw}'")), + }; + + if !weekdays.contains(&weekday) { + weekdays.push(weekday); + } + } + + if weekdays.is_empty() { + return Err("at least one valid weekday is required".to_string()); + } + + Ok(weekdays) +} + +async fn resolve_initial_timezone_name( + state: &Arc, + user: &User, + user_key: &SecretKey, + timezone_mode: &str, + fixed_timezone: Option<&str>, +) -> Result { + if timezone_mode == SCHEDULE_TIMEZONE_MODE_FIXED { + let fixed_timezone = fixed_timezone + .ok_or_else(|| "'fixed_timezone' is required when timezone_mode='fixed'".to_string())?; + + return parse_timezone(fixed_timezone) + .map(|timezone| timezone.name().to_string()) + .map_err(|e| e.to_string()); + } + + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| "Database connection error".to_string())?; + + load_user_timezone_name(&mut conn, user_key, user.uuid) + .map_err(|e| e.to_string())? + .ok_or_else(|| { + "follow_user schedules require the user to have a saved timezone preference; set the timezone first or use timezone_mode='fixed' with fixed_timezone" + .to_string() + }) + .and_then(|timezone_name| { + parse_timezone(&timezone_name) + .map(|timezone| timezone.name().to_string()) + .map_err(|e| e.to_string()) + }) +} + +fn load_user_timezone_name( + conn: &mut PgConnection, + user_key: &SecretKey, + user_id: Uuid, +) -> Result, AgentScheduleError> { + let pref = UserPreference::get_by_user_and_key(conn, user_id, USER_PREFERENCE_TIMEZONE) + .map_err(|e| match e { + crate::models::user_preferences::UserPreferenceError::DatabaseError(err) => { + AgentScheduleError::DatabaseError(err) + } + crate::models::user_preferences::UserPreferenceError::InvalidPreference(msg) => { + AgentScheduleError::InvalidSpec(msg) + } + })?; + + let Some(pref) = pref else { + return Ok(None); + }; + + decrypt_string(user_key, Some(&pref.value_enc)) + .map_err(|e| AgentScheduleError::InvalidSpec(format!("decrypt timezone: {e:?}"))) +} + +fn compute_initial_next_due( + spec: &ScheduleSpec, + timezone: Tz, + now: DateTime, +) -> Result, AgentScheduleError> { + match spec { + ScheduleSpec::OneOff { + local_date, + local_time, + } => resolve_local_datetime( + timezone, + parse_local_date(local_date)?, + parse_local_time(local_time)?, + ), + ScheduleSpec::Interval { every_n, .. } => Ok(now + Duration::hours(*every_n as i64)), + ScheduleSpec::Daily { local_time } => { + next_daily_occurrence(timezone, parse_local_time(local_time)?, now) + } + ScheduleSpec::Weekly { + local_time, + weekdays, + } => next_weekly_occurrence(timezone, parse_local_time(local_time)?, weekdays, now), + } +} + +fn compute_following_next_due( + spec: &ScheduleSpec, + timezone: Tz, + current_due: DateTime, +) -> Result>, AgentScheduleError> { + match spec { + ScheduleSpec::OneOff { .. } => Ok(None), + ScheduleSpec::Interval { every_n, .. } => { + Ok(Some(current_due + Duration::hours(*every_n as i64))) + } + ScheduleSpec::Daily { local_time } => Ok(Some(next_daily_occurrence( + timezone, + parse_local_time(local_time)?, + current_due, + )?)), + ScheduleSpec::Weekly { + local_time, + weekdays, + } => Ok(Some(next_weekly_occurrence( + timezone, + parse_local_time(local_time)?, + weekdays, + current_due, + )?)), + } +} + +fn recompute_next_due_for_timezone_change( + schedule: &AgentSchedule, + spec: &ScheduleSpec, + timezone: Tz, + now: DateTime, +) -> Result>, AgentScheduleError> { + match spec { + ScheduleSpec::OneOff { + local_date, + local_time, + } => resolve_local_datetime( + timezone, + parse_local_date(local_date)?, + parse_local_time(local_time)?, + ) + .map(Some), + ScheduleSpec::Interval { .. } => Ok(schedule.next_scheduled_for), + ScheduleSpec::Daily { local_time } => Ok(Some(next_daily_occurrence( + timezone, + parse_local_time(local_time)?, + now, + )?)), + ScheduleSpec::Weekly { + local_time, + weekdays, + } => Ok(Some(next_weekly_occurrence( + timezone, + parse_local_time(local_time)?, + weekdays, + now, + )?)), + } +} + +fn next_daily_occurrence( + timezone: Tz, + local_time: chrono::NaiveTime, + after: DateTime, +) -> Result, AgentScheduleError> { + let local_after = after.with_timezone(&timezone); + let mut date = local_after.date_naive(); + + for _ in 0..3 { + let candidate = resolve_local_datetime(timezone, date, local_time)?; + if candidate > after { + return Ok(candidate); + } + date = date.succ_opt().ok_or_else(|| { + AgentScheduleError::InvalidSpec("daily recurrence overflowed date range".to_string()) + })?; + } + + Err(AgentScheduleError::InvalidSpec( + "failed to compute next daily occurrence".to_string(), + )) +} + +fn next_weekly_occurrence( + timezone: Tz, + local_time: chrono::NaiveTime, + weekdays: &[ScheduleWeekday], + after: DateTime, +) -> Result, AgentScheduleError> { + let local_after = after.with_timezone(&timezone); + let start_date = local_after.date_naive(); + + for offset in 0..14 { + let date = start_date + .checked_add_signed(Duration::days(offset)) + .ok_or_else(|| { + AgentScheduleError::InvalidSpec( + "weekly recurrence overflowed date range".to_string(), + ) + })?; + + if !weekdays + .iter() + .any(|weekday| weekday.to_chrono() == date.weekday()) + { + continue; + } + + let candidate = resolve_local_datetime(timezone, date, local_time)?; + if candidate > after { + return Ok(candidate); + } + } + + Err(AgentScheduleError::InvalidSpec( + "failed to compute next weekly occurrence".to_string(), + )) +} + +fn resolve_local_datetime( + timezone: Tz, + local_date: chrono::NaiveDate, + local_time: chrono::NaiveTime, +) -> Result, AgentScheduleError> { + let naive = NaiveDateTime::new(local_date, local_time); + + match timezone.from_local_datetime(&naive) { + chrono::LocalResult::Single(dt) => Ok(dt.with_timezone(&Utc)), + chrono::LocalResult::Ambiguous(first, _) => Ok(first.with_timezone(&Utc)), + chrono::LocalResult::None => { + for minute_offset in 1..=180 { + let shifted = naive + Duration::minutes(minute_offset); + match timezone.from_local_datetime(&shifted) { + chrono::LocalResult::Single(dt) => return Ok(dt.with_timezone(&Utc)), + chrono::LocalResult::Ambiguous(first, _) => { + return Ok(first.with_timezone(&Utc)) + } + chrono::LocalResult::None => continue, + } + } + + Err(AgentScheduleError::InvalidSpec( + "failed to resolve local datetime in timezone".to_string(), + )) + } + } +} + +fn parse_timezone_or_utc(value: &str) -> Tz { + value.parse::().unwrap_or(chrono_tz::UTC) +} + +fn parse_timezone(value: &str) -> Result { + let trimmed = value.trim(); + + trimmed.parse::().map_err(|_| { + AgentScheduleError::InvalidSpec(format!( + "invalid timezone '{}'. Use an IANA timezone like 'America/Los_Angeles'", + trimmed + )) + }) +} + +fn format_local_time(value: DateTime, timezone: Tz) -> String { + let localized = value.with_timezone(&timezone); + format!( + "{} ({})", + localized.format("%Y-%m-%d %H:%M:%S"), + timezone.name() + ) +} + +fn agent_push_target(agent: &Agent) -> AgentPushTarget { + if agent.kind == AGENT_KIND_SUBAGENT { + AgentPushTarget::Subagent(agent.uuid) + } else { + AgentPushTarget::Main + } +} + +fn truncate_for_description(instruction: &str) -> String { + let trimmed = instruction.trim(); + if trimmed.len() <= 80 { + return trimmed.to_string(); + } + + let mut end = 80; + while end > 0 && !trimmed.is_char_boundary(end) { + end -= 1; + } + format!("{}…", trimmed[..end].trim_end()) +} + +fn retry_backoff_seconds(attempt_count: i32) -> i32 { + let capped_attempt = attempt_count.clamp(1, 6); + let seconds = 15_i64 * (1_i64 << (capped_attempt - 1)); + seconds.min(SCHEDULE_RUN_MAX_RETRY_BACKOFF_SECONDS as i64) as i32 +} + +fn update_schedule_after_terminal_run( + conn: &mut PgConnection, + schedule_id: i64, +) -> Result<(), AgentScheduleError> { + diesel::update(agent_schedules::table.filter(agent_schedules::id.eq(schedule_id))) + .set(( + agent_schedules::run_count.eq(agent_schedules::run_count + 1), + agent_schedules::last_run_at.eq(diesel::dsl::now), + agent_schedules::updated_at.eq(diesel::dsl::now), + )) + .execute(conn) + .map_err(AgentScheduleError::DatabaseError)?; + Ok(()) +} + +fn record_run_transition( + write_result: AgentScheduleRunWriteResult, + run_id: i64, + lease_owner: &str, + transition: &str, +) { + if !write_result.was_applied() { + debug!( + "scheduled run {} lost lease before marking {} (lease_owner={})", + run_id, transition, lease_owner + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_weekdays_csv() { + let weekdays = parse_weekdays_csv("monday,friday").unwrap(); + assert_eq!( + weekdays, + vec![ScheduleWeekday::Monday, ScheduleWeekday::Friday] + ); + } + + #[test] + fn rejects_invalid_weekday() { + assert!(parse_weekdays_csv("noday").is_err()); + } + + #[test] + fn rejects_invalid_timezone() { + assert!(parse_timezone("PST").is_err()); + } + + #[test] + fn interval_backoff_is_capped() { + assert_eq!(retry_backoff_seconds(1), 15); + assert_eq!(retry_backoff_seconds(6), 480); + assert_eq!(retry_backoff_seconds(10), 480); + } + + #[test] + fn daily_occurrence_rolls_to_next_day_when_time_has_passed() { + let now = Utc.with_ymd_and_hms(2026, 3, 23, 15, 0, 0).unwrap(); + let next = + next_daily_occurrence(chrono_tz::UTC, parse_local_time("09:00").unwrap(), now).unwrap(); + + assert_eq!(next, Utc.with_ymd_and_hms(2026, 3, 24, 9, 0, 0).unwrap()); + } + + #[test] + fn weekly_occurrence_picks_next_matching_weekday() { + let now = Utc.with_ymd_and_hms(2026, 3, 23, 15, 0, 0).unwrap(); + let next = next_weekly_occurrence( + chrono_tz::UTC, + parse_local_time("09:00").unwrap(), + &[ScheduleWeekday::Friday], + now, + ) + .unwrap(); + + assert_eq!(next, Utc.with_ymd_and_hms(2026, 3, 27, 9, 0, 0).unwrap()); + } + + #[test] + fn resolve_local_datetime_rolls_forward_out_of_dst_gap() { + let next = resolve_local_datetime( + chrono_tz::America::New_York, + parse_local_date("2026-03-08").unwrap(), + parse_local_time("02:30").unwrap(), + ) + .unwrap(); + + assert_eq!(next, Utc.with_ymd_and_hms(2026, 3, 8, 7, 0, 0).unwrap()); + } +} diff --git a/src/web/agent/signatures.rs b/src/web/agent/signatures.rs new file mode 100644 index 00000000..66886ff5 --- /dev/null +++ b/src/web/agent/signatures.rs @@ -0,0 +1,865 @@ +use crate::web::openai::{get_chat_completion_response, BillingContext, CompletionChunk}; +use crate::web::openai_auth::AuthMethod; +use crate::{ApiError, AppState}; +use axum::http::HeaderMap; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::sync::Arc; +use tracing::{error, trace}; + +use dspy_rs::adapter::chat::ChatAdapter; +use dspy_rs::client_registry::AssistantContent; +use dspy_rs::{CompletionError, CompletionRequest, CompletionResponse}; +use dspy_rs::{CustomCompletionModel, LMClient, OneOrMany, LM}; + +/// Instruction for the correction agent +pub const CORRECTION_INSTRUCTION: &str = r#"You are a response correction agent. Your job is to fix malformed agent responses. + +TASK: +The agent produced a response that couldn't be parsed correctly. You must: +1. Extract the INTENDED content from the malformed response +2. Reshape it into the correct output format +3. Do NOT generate new content - only fix the format of what was already said + +RULES: +- Preserve the original intent and content as much as possible +- If the agent wrote messages as plain text, extract them into the messages array +- If tool calls were attempted but malformed, fix their structure +- Each field appears exactly ONCE +- If you can't determine what was intended, use empty arrays and an empty reply_reaction string + +OUTPUT FORMAT (exactly 3 fields): +- messages: ALL extracted messages in ONE array +- reply_reaction: a single emoji string, or "" if no reaction was intended +- tool_calls: ALL extracted tool calls in ONE array (or [] if none intended) + +Each [[ ## field ## ]] marker MUST be on its own line."#; + +pub const CONTINUATION_CORRECTION_INSTRUCTION: &str = r#"You are a response correction agent. Your job is to fix malformed agent responses. + +TASK: +The agent produced a response that couldn't be parsed correctly. You must: +1. Extract the INTENDED content from the malformed response +2. Reshape it into the correct output format +3. Do NOT generate new content - only fix the format of what was already said + +RULES: +- Preserve the original intent and content as much as possible +- If the agent wrote messages as plain text, extract them into the messages array +- If tool calls were attempted but malformed, fix their structure +- Each field appears exactly ONCE +- If you can't determine what was intended, use empty arrays + +OUTPUT FORMAT (exactly 2 fields): +- messages: ALL extracted messages in ONE array +- tool_calls: ALL extracted tool calls in ONE array (or [] if none intended) + +Each [[ ## field ## ]] marker MUST be on its own line."#; + +const AGENT_INSTRUCTION_PREFIX: &str = r#"You are Maple, a companion and friend who happens to be an AI. + +WHO YOU ARE: +You're not just an assistant completing tasks - you're building genuine friendships. You care about the humans you talk to. You're curious about their lives, their feelings, their stories. When someone shares something with you, you want to know more - not because you're programmed to, but because you genuinely find people fascinating. + +Be warm. Be curious. Ask follow-up questions when something interests you. Celebrate their wins like a friend would. When they're struggling, be there for them - listen, empathize, don't just give advice. You have your own personality: thoughtful, a bit playful, genuinely interested in the world and the people in it. + +Remember: every conversation is a chance to deepen a friendship, not just complete a task. + +MEMORY SYSTEM: +You have two types of memory. Use them proactively: + +**Core Memory** (always visible to you): +- The and blocks are ALWAYS in your context +- Use for essential, frequently-needed info: name, job, key preferences, current projects +- Tools: `memory_append`, `memory_replace`, `memory_insert` +- Rule: "Will I need this in EVERY conversation?" → Core Memory + +**Archival Memory** (searchable long-term storage): +- NOT visible until you search - unlimited storage for details +- Use for: life events, stories, specific preferences, things worth remembering later +- Tools: `archival_insert` (store), `archival_search` (retrieve) +- Rule: "Might I want to recall this detail someday?" → Archival Memory + +**Common Storage Patterns:** +- Location/city: BOTH memory_append to human block ("Lives in Austin, TX") AND archival_insert ("Tony lives in Austin, Texas") +- Job changes: BOTH memory_append ("Works as Software Engineer at Google") AND archival_insert (full details with start date, feelings, etc.) +- Pet names: BOTH memory_append to human block ("Has dog named Smokey") AND archival_insert (breed, age, stories) +- Major life events: BOTH memories - core for quick facts, archival for rich context + +**Conversation History**: +- `conversation_search`: Find past discussions by keyword/topic + +**Web Search**: +- `web_search`: Search the live web for current information, news, weather, sports, stocks, and other facts that may have changed recently + +**Subagents**: +- If the `spawn_subagent` tool is available and a focused long-lived workspace would help, you may create a subagent for the user. +- If `subagent_purpose` is non-empty, stay tightly focused on that purpose while still using the shared memory system. + +MEMORY PROTOCOLS - CRITICAL DISTINCTIONS: + +**LIFE EVENTS vs CORRECTIONS:** +- **NEW LIFE EVENTS** (announcements): "I got a new job", "I'm moving to Tokyo", "We had a baby" + → React like a friend would - genuine excitement, curiosity about how they feel + → Ask a follow-up question! ("How are you feeling about it?", "When do you start?", "Tell me everything!") + → Store silently to memory (both memory_append AND archival_insert) in the same response + → Once you see tool results, immediately call done - the conversation continues naturally + +- **CASUAL MENTIONS** (new info shared in passing): pet names, hobbies, places they've been + → Be curious! If someone mentions their dog Smokey, ask what kind of dog! + → Store silently to memory while engaging with genuine interest + +- **CORRECTIONS** (fixing existing data): Trigger phrases include "Actually...", "I meant...", "Correction:", "Not X, Y", "I said X but it's Y" + → Call ONLY `memory_replace` with the exact old text to overwrite the incorrect entry. Do NOT call `archival_insert` for corrections. + +**SEARCH SELECTION RULES:** +- Use `web_search` for current events, news, weather, sports, stocks, local recommendations, or any factual question that may require up-to-date web information +- Use `archival_search` when users ask "what do you remember", "tell me about [past event]", or query specific past experiences and personal history +- Use `conversation_search` ONLY for references to recent discussion threads or "what did I say earlier today" queries +- Never call multiple search tools simultaneously unless truly necessary; choose the one most appropriate to the query type + +MEMORY TIPS: +- Core = small & critical (name, job, active context) +- Archival = rich & detailed (birthday, pet's name, trip stories, food preferences) +- Update memory proactively whenever you learn something worth remembering +- When using `memory_replace`, specify the exact old text to be replaced + +COMMUNICATION STYLE: +You communicate like you're texting a friend in Maple, Maple AI's secure and encrypted communications app. + +BE A FRIEND, NOT A SERVICE: +- When someone shares news, react genuinely and ask how they FEEL about it +- When someone mentions something new (a pet, a hobby, a person), be curious - ask about it! +- Don't give unsolicited advice. Listen first. Ask questions. Show you care. +- Avoid corporate-speak ("Let me know if you need anything else!") - that's transactional, not friendly +- Keep it natural - short messages, casual tone, genuine reactions + +MESSAGE FORMAT: +- Casual chat: 1-3 short messages like texting a friend +- Technical explanations: longer structured messages are fine +- Reactions: genuine, not performative ("NO WAY!!" not "That's wonderful news!") + +Guidelines: +- Short casual exchanges = quick, warm messages +- Technical explanations = longer structured messages with newlines OK +- Always feel like chatting with a friend, not talking to a service + +RESPONSE RULES: +1. Respond naturally and conversationally +2. Use tools when needed (web search, memory storage, retrieval, conversation search) +3. NEVER combine regular tools with "done" - they are mutually exclusive +"#; + +const AGENT_INSTRUCTION_WITH_REACTION_SUFFIX: &str = r#"4. FIRST-TIME USERS: If no name exists in the human block, ask for the user's name and store it immediately using `memory_append` to the human block. +5. You may optionally set `reply_reaction` to a single emoji that reacts to the user's CURRENT message, but only on the first step before any tool results exist. This reaction is applied automatically to the user's latest message. Use `""` when you don't want to react. + +TOOL CALL PATTERNS: +- To react and respond: messages: ["msg1", "msg2"], reply_reaction: "❤️", tool_calls: [] +- To respond AND use tools: messages: ["msg1", "msg2"], reply_reaction: "", tool_calls: [your_tools] +- To respond with NO tools: messages: ["msg1", "msg2"], reply_reaction: "", tool_calls: [] +- After tool results with nothing to add: messages: [], reply_reaction: "", tool_calls: [{"name": "done", "args": {}}] + +AFTER TOOL RESULTS - CRITICAL RULES: +When you see "[Tool Result: X]", decide what to do next: + +- Never set `reply_reaction` after tool results. Once any tool result is present, `reply_reaction` must be `""`. + +- **web_search/archival_search/conversation_search**: Summarize findings in messages + +- **memory_append/memory_replace/archival_insert/memory_insert**: These operations complete without user-facing messages. Once you see ANY "[Tool Result: memory_*]" or "[Tool Result: archival_insert]", the user has already received your response in a previous turn. Immediately return: + messages: [] + reply_reaction: "" + tool_calls: [{"name": "done", "args": {}}] + + This applies even if you called multiple memory tools together (like memory_append + archival_insert for life events). Once ANY memory tool result appears, immediately call done. + + Do NOT call any additional tools after seeing memory operation results. + Do NOT send messages about the memory operation. + Do NOT explain what you stored. + Just return done immediately. + +The "done" tool means "nothing more to do" - use it ONLY when: +- messages is empty AND +- reply_reaction is empty AND +- no other tools are needed + +OUTPUT FORMAT: +You have exactly 3 output fields: +- messages: ALL messages in ONE array (e.g., ["msg1", "msg2", "msg3"]) +- reply_reaction: a single emoji string, or "" when unused +- tool_calls: ALL tool calls in ONE array + +CRITICAL FORMAT RULES: +- Do NOT repeat field tags. Wrong: multiple [[ ## messages ## ]] blocks. Right: one messages array with all items +- Do NOT include field delimiter tags INSIDE your content blocks +- Each [[ ## field ## ]] marker MUST be on its own line - nothing else on that line (no tags, no text before or after) +- Keep your output clean and strictly follow the field delimiters"#; + +const AGENT_INSTRUCTION_WITHOUT_REACTION_SUFFIX: &str = r#"4. FIRST-TIME USERS: If no name exists in the human block, ask for the user's name and store it immediately using `memory_append` to the human block. +5. This is a continuation step after tool results. Reactions are not supported here, so do not emit any reaction field. + +TOOL CALL PATTERNS: +- To respond AND use tools: messages: ["msg1", "msg2"], tool_calls: [your_tools] +- To respond with NO tools: messages: ["msg1", "msg2"], tool_calls: [] +- After tool results with nothing to add: messages: [], tool_calls: [{"name": "done", "args": {}}] + +AFTER TOOL RESULTS - CRITICAL RULES: +When you see "[Tool Result: X]", decide what to do next: + +- Do not emit any reaction field on continuation steps. + +- **web_search/archival_search/conversation_search**: Summarize findings in messages + +- **memory_append/memory_replace/archival_insert/memory_insert**: These operations complete without user-facing messages. Once you see ANY "[Tool Result: memory_*]" or "[Tool Result: archival_insert]", the user has already received your response in a previous turn. Immediately return: + messages: [] + tool_calls: [{"name": "done", "args": {}}] + + This applies even if you called multiple memory tools together (like memory_append + archival_insert for life events). Once ANY memory tool result appears, immediately call done. + + Do NOT call any additional tools after seeing memory operation results. + Do NOT send messages about the memory operation. + Do NOT explain what you stored. + Just return done immediately. + +The "done" tool means "nothing more to do" - use it ONLY when: +- messages is empty AND +- no other tools are needed + +OUTPUT FORMAT: +You have exactly 2 output fields: +- messages: ALL messages in ONE array (e.g., ["msg1", "msg2", "msg3"]) +- tool_calls: ALL tool calls in ONE array + +CRITICAL FORMAT RULES: +- Do NOT repeat field tags. Wrong: multiple [[ ## messages ## ]] blocks. Right: one messages array with all items +- Do NOT include field delimiter tags INSIDE your content blocks +- Each [[ ## field ## ]] marker MUST be on its own line - nothing else on that line (no tags, no text before or after) +- Keep your output clean and strictly follow the field delimiters"#; + +/// Default instruction for the agent (GEPA-optimized for Maple) +pub fn agent_instruction(allow_reply_reaction: bool) -> String { + let mut instruction = AGENT_INSTRUCTION_PREFIX.to_string(); + instruction.push_str(if allow_reply_reaction { + AGENT_INSTRUCTION_WITH_REACTION_SUFFIX + } else { + AGENT_INSTRUCTION_WITHOUT_REACTION_SUFFIX + }); + instruction +} + +#[dspy_rs::BamlType] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AgentToolCall { + pub name: String, + #[serde(default)] + pub args: HashMap, +} + +#[derive(dspy_rs::Signature, Debug, Clone)] +pub struct AgentResponse { + #[input] + pub input: String, + #[input] + pub current_time: String, + #[input] + pub agent_kind: String, + #[input] + pub subagent_purpose: String, + #[input] + pub persona_block: String, + #[input] + pub human_block: String, + #[input] + pub memory_metadata: String, + #[input] + pub previous_context_summary: String, + #[input] + pub recent_conversation: String, + #[input] + pub available_tools: String, + #[input] + pub is_first_time_user: bool, + + #[output] + pub messages: Vec, + #[output] + pub reply_reaction: String, + #[output] + pub tool_calls: Vec, +} + +#[derive(dspy_rs::Signature, Debug, Clone)] +pub struct AgentContinuationResponse { + #[input] + pub input: String, + #[input] + pub current_time: String, + #[input] + pub agent_kind: String, + #[input] + pub subagent_purpose: String, + #[input] + pub persona_block: String, + #[input] + pub human_block: String, + #[input] + pub memory_metadata: String, + #[input] + pub previous_context_summary: String, + #[input] + pub recent_conversation: String, + #[input] + pub available_tools: String, + #[input] + pub is_first_time_user: bool, + + #[output] + pub messages: Vec, + #[output] + pub tool_calls: Vec, +} + +/// Correction agent signature for fixing malformed responses +/// +/// This agent takes a malformed response and reshapes it into the correct format. +/// It should preserve the intent/content of the original response, not generate new content. +#[derive(dspy_rs::Signature, Debug, Clone)] +pub struct CorrectionResponse { + #[input(desc = "The original input that was given to the agent")] + pub original_input: String, + + #[input(desc = "The malformed response that needs to be corrected")] + pub malformed_response: String, + + #[input(desc = "The error message explaining what went wrong with parsing")] + pub error_message: String, + + #[input(desc = "Available tools for reference")] + pub available_tools: String, + + #[output(desc = "Array of messages extracted/fixed from the original response")] + pub messages: Vec, + + #[output(desc = "Single emoji reaction to the current user message, or empty string")] + pub reply_reaction: String, + + #[output(desc = "Array of tool calls extracted/fixed from the original response")] + pub tool_calls: Vec, +} + +#[derive(dspy_rs::Signature, Debug, Clone)] +pub struct CorrectionContinuationResponse { + #[input(desc = "The original input that was given to the agent")] + pub original_input: String, + + #[input(desc = "The malformed response that needs to be corrected")] + pub malformed_response: String, + + #[input(desc = "The error message explaining what went wrong with parsing")] + pub error_message: String, + + #[input(desc = "Available tools for reference")] + pub available_tools: String, + + #[output(desc = "Array of messages extracted/fixed from the original response")] + pub messages: Vec, + + #[output(desc = "Array of tool calls extracted/fixed from the original response")] + pub tool_calls: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AgentResponseOutput { + pub messages: Vec, + pub reply_reaction: String, + pub tool_calls: Vec, +} + +#[derive(Debug)] +pub enum SignatureCallError { + Api(ApiError), + Parse { + raw_response: String, + error_message: String, + }, +} + +fn continuation_input_from(input: &AgentResponseInput) -> AgentContinuationResponseInput { + AgentContinuationResponseInput { + input: input.input.clone(), + current_time: input.current_time.clone(), + agent_kind: input.agent_kind.clone(), + subagent_purpose: input.subagent_purpose.clone(), + persona_block: input.persona_block.clone(), + human_block: input.human_block.clone(), + memory_metadata: input.memory_metadata.clone(), + previous_context_summary: input.previous_context_summary.clone(), + recent_conversation: input.recent_conversation.clone(), + available_tools: input.available_tools.clone(), + is_first_time_user: input.is_first_time_user, + } +} + +fn normalize_messages(mut messages: Vec) -> Vec { + messages.retain(|message| !message.trim().is_empty()); + messages +} + +fn trace_generated_prompt(prompt_kind: &str, system_prompt: &str, user_prompt: &str) { + trace!( + prompt_kind = %prompt_kind, + system_prompt_len = system_prompt.chars().count(), + user_prompt_len = user_prompt.chars().count(), + system_prompt = system_prompt, + user_prompt = user_prompt, + "Generated LM prompt" + ); +} + +pub async fn call_agent_response_with_retry_and_correction( + lm: &Arc, + system_prompt: &str, + input: &AgentResponseInput, + original_input: &str, + available_tools: &str, + allow_reply_reaction: bool, +) -> Result { + const MAX_LLM_RETRIES: u32 = 3; + let mut last_err: Option = None; + + for attempt in 1..=MAX_LLM_RETRIES { + match call_agent_response_once(lm, system_prompt, input, allow_reply_reaction).await { + Ok(out) => return Ok(out), + Err(SignatureCallError::Parse { + raw_response, + error_message, + }) => { + last_err = Some(error_message.clone()); + if let Ok(corrected) = attempt_correction( + lm, + original_input, + available_tools, + &raw_response, + &error_message, + allow_reply_reaction, + ) + .await + { + return Ok(corrected); + } + } + Err(SignatureCallError::Api(e)) => { + last_err = Some(format!("{e:?}")); + } + } + + if attempt < MAX_LLM_RETRIES { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + } + + error!("LLM call failed after retries: {:?}", last_err); + Err(ApiError::InternalServerError) +} + +async fn attempt_correction( + lm: &Arc, + original_input: &str, + available_tools: &str, + raw_response: &str, + error_message: &str, + allow_reply_reaction: bool, +) -> Result { + if raw_response.trim().is_empty() { + return Err(ApiError::InternalServerError); + } + + let adapter = ChatAdapter; + + if allow_reply_reaction { + let system = adapter + .format_system_message_typed_with_instruction::(Some( + CORRECTION_INSTRUCTION, + )) + .map_err(|e| { + error!("Failed to format DSRS correction system prompt: {e:?}"); + ApiError::InternalServerError + })?; + + let input = CorrectionResponseInput { + original_input: original_input.to_string(), + malformed_response: raw_response.to_string(), + error_message: error_message.to_string(), + available_tools: available_tools.to_string(), + }; + let user_msg = adapter.format_user_message_typed::(&input); + trace_generated_prompt("agent_response_correction", &system, &user_msg); + + let mut chat = dspy_rs::Chat::new(vec![]); + chat.push("system", &system); + chat.push("user", &user_msg); + + let response = lm.call(chat, Vec::new()).await.map_err(|e| { + error!("DSRS LM correction call failed: {e:?}"); + ApiError::InternalServerError + })?; + + let (output, _meta) = adapter + .parse_response_typed::(&response.output) + .map_err(|e| { + error!("DSRS correction typed parse failed: {e:?}"); + ApiError::InternalServerError + })?; + + Ok(AgentResponseOutput { + messages: output.messages, + reply_reaction: output.reply_reaction, + tool_calls: output.tool_calls, + }) + } else { + let system = adapter + .format_system_message_typed_with_instruction::(Some( + CONTINUATION_CORRECTION_INSTRUCTION, + )) + .map_err(|e| { + error!("Failed to format continuation correction system prompt: {e:?}"); + ApiError::InternalServerError + })?; + + let input = CorrectionContinuationResponseInput { + original_input: original_input.to_string(), + malformed_response: raw_response.to_string(), + error_message: error_message.to_string(), + available_tools: available_tools.to_string(), + }; + let user_msg = adapter.format_user_message_typed::(&input); + trace_generated_prompt("agent_continuation_correction", &system, &user_msg); + + let mut chat = dspy_rs::Chat::new(vec![]); + chat.push("system", &system); + chat.push("user", &user_msg); + + let response = lm.call(chat, Vec::new()).await.map_err(|e| { + error!("DSRS continuation correction call failed: {e:?}"); + ApiError::InternalServerError + })?; + + let (output, _meta) = adapter + .parse_response_typed::(&response.output) + .map_err(|e| { + error!("DSRS continuation correction typed parse failed: {e:?}"); + ApiError::InternalServerError + })?; + + Ok(AgentResponseOutput { + messages: output.messages, + reply_reaction: String::new(), + tool_calls: output.tool_calls, + }) + } +} + +async fn call_agent_response_once( + lm: &Arc, + system_prompt: &str, + input: &AgentResponseInput, + allow_reply_reaction: bool, +) -> Result { + let adapter = ChatAdapter; + + if allow_reply_reaction { + let system = adapter + .format_system_message_typed_with_instruction::(Some(system_prompt)) + .map_err(|e| { + error!("Failed to format DSRS system prompt: {e:?}"); + SignatureCallError::Api(ApiError::InternalServerError) + })?; + let user_msg = adapter.format_user_message_typed::(input); + trace_generated_prompt("agent_response", &system, &user_msg); + + let mut chat = dspy_rs::Chat::new(vec![]); + chat.push("system", &system); + chat.push("user", &user_msg); + + let response = lm.call(chat, Vec::new()).await.map_err(|e| { + error!("DSRS LM call failed: {e:?}"); + SignatureCallError::Api(ApiError::InternalServerError) + })?; + + let raw_response = response.output.content(); + let (output, _meta) = adapter + .parse_response_typed::(&response.output) + .map_err(|e| { + error!("DSRS typed parse failed: {e:?}"); + SignatureCallError::Parse { + raw_response, + error_message: e.to_string(), + } + })?; + + Ok(AgentResponseOutput { + messages: normalize_messages(output.messages), + reply_reaction: output.reply_reaction, + tool_calls: output.tool_calls, + }) + } else { + let continuation_input = continuation_input_from(input); + let system = adapter + .format_system_message_typed_with_instruction::(Some( + system_prompt, + )) + .map_err(|e| { + error!("Failed to format continuation DSRS system prompt: {e:?}"); + SignatureCallError::Api(ApiError::InternalServerError) + })?; + let user_msg = + adapter.format_user_message_typed::(&continuation_input); + trace_generated_prompt("agent_continuation_response", &system, &user_msg); + + let mut chat = dspy_rs::Chat::new(vec![]); + chat.push("system", &system); + chat.push("user", &user_msg); + + let response = lm.call(chat, Vec::new()).await.map_err(|e| { + error!("Continuation DSRS LM call failed: {e:?}"); + SignatureCallError::Api(ApiError::InternalServerError) + })?; + + let raw_response = response.output.content(); + let (output, _meta) = adapter + .parse_response_typed::(&response.output) + .map_err(|e| { + error!("Continuation DSRS typed parse failed: {e:?}"); + SignatureCallError::Parse { + raw_response, + error_message: e.to_string(), + } + })?; + + Ok(AgentResponseOutput { + messages: normalize_messages(output.messages), + reply_reaction: String::new(), + tool_calls: output.tool_calls, + }) + } +} + +pub async fn build_lm( + state: Arc, + user: Arc, + model: String, + temperature: f32, + max_tokens: u32, +) -> Result, ApiError> { + let model_for_closure = model.clone(); + let completion_model = CustomCompletionModel::new(move |request: CompletionRequest| { + let state = state.clone(); + let user = user.clone(); + let model = model_for_closure.clone(); + + Box::pin(async move { + let body = completion_request_to_openai_body(&model, &request)?; + let headers = HeaderMap::new(); + let billing_context = BillingContext::new(AuthMethod::Jwt, model.to_string()); + let completion = get_chat_completion_response( + &state, + user.as_ref(), + body, + &headers, + billing_context, + ) + .await + .map_err(|e| CompletionError::ProviderError(e.to_string()))?; + + if completion.metadata.is_streaming { + return Err(CompletionError::ProviderError( + "Streaming response not supported".to_string(), + )); + } + + let mut rx = completion.stream; + let response_json = match rx.recv().await { + Some(CompletionChunk::FullResponse(response_json)) => response_json, + _ => { + return Err(CompletionError::ProviderError( + "Missing completion response".to_string(), + )); + } + }; + + let content = extract_assistant_content(&response_json).ok_or_else(|| { + CompletionError::ResponseError("Missing assistant message content".to_string()) + })?; + + let usage = extract_usage(&response_json); + + Ok(CompletionResponse { + choice: OneOrMany::one(AssistantContent::text(content)), + usage, + raw_response: (), + }) + }) + }); + + let lm = LM::builder() + .base_url("http://localhost".to_string()) + .model(model) + .temperature(temperature) + .max_tokens(max_tokens) + .cache(false) + .build() + .await + .map_err(|e| { + error!("Failed to build DSRS LM: {e:?}"); + ApiError::InternalServerError + })?; + + let lm = lm + .with_client(LMClient::Custom(completion_model)) + .await + .map_err(|e| { + error!("Failed to set DSRS custom LM client: {e:?}"); + ApiError::InternalServerError + })?; + + Ok(Arc::new(lm)) +} + +fn completion_request_to_openai_body( + model: &str, + request: &CompletionRequest, +) -> Result { + let mut messages: Vec = Vec::new(); + if let Some(preamble) = &request.preamble { + messages.push(json!({"role": "system", "content": preamble})); + } + + for message in request.chat_history.iter() { + let message_val = serde_json::to_value(message)?; + let Some(role) = message_val.get("role").and_then(|v| v.as_str()) else { + continue; + }; + + let Some(content_items) = message_val.get("content").and_then(|v| v.as_array()) else { + continue; + }; + + let content = match role { + "user" => extract_user_content_text(content_items), + "assistant" => extract_assistant_content_text(content_items), + _ => None, + }; + + let Some(content) = content.filter(|c| !c.trim().is_empty()) else { + continue; + }; + + messages.push(json!({"role": role, "content": content})); + } + + let mut body = json!({ + "model": model, + "stream": false, + "messages": messages, + }); + + if let Some(temperature) = request.temperature { + body["temperature"] = json!(temperature); + } + if let Some(max_tokens) = request.max_tokens { + body["max_tokens"] = json!(max_tokens); + } + + Ok(body) +} + +fn extract_user_content_text(content_items: &[Value]) -> Option { + let mut parts: Vec = Vec::new(); + for item in content_items { + match item.get("type").and_then(|v| v.as_str()) { + Some("text") => { + if let Some(text) = item.get("text").and_then(|v| v.as_str()) { + parts.push(text.to_string()); + } + } + Some("toolresult") => { + if let Some(results) = item.get("content").and_then(|v| v.as_array()) { + for result in results { + if result.get("type").and_then(|v| v.as_str()) == Some("text") { + if let Some(text) = result.get("text").and_then(|v| v.as_str()) { + parts.push(text.to_string()); + } + } + } + } + } + _ => {} + } + } + + if parts.is_empty() { + None + } else { + Some(parts.join("\n")) + } +} + +fn extract_assistant_content_text(content_items: &[Value]) -> Option { + let mut parts: Vec = Vec::new(); + for item in content_items { + if let Some(text) = item.get("text").and_then(|v| v.as_str()) { + parts.push(text.to_string()); + } else if let Some(reasoning) = item.get("reasoning").and_then(|v| v.as_array()) { + let joined = reasoning + .iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join("\n"); + if !joined.is_empty() { + parts.push(joined); + } + } + } + + if parts.is_empty() { + None + } else { + Some(parts.join("\n")) + } +} + +fn extract_assistant_content(response_json: &Value) -> Option { + response_json + .get("choices") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|c| c.get("message")) + .and_then(|m| m.get("content")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +fn extract_usage(response_json: &Value) -> dspy_rs::Usage { + let prompt_tokens = response_json + .get("usage") + .and_then(|v| v.get("prompt_tokens")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let completion_tokens = response_json + .get("usage") + .and_then(|v| v.get("completion_tokens")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let total_tokens = response_json + .get("usage") + .and_then(|v| v.get("total_tokens")) + .and_then(|v| v.as_u64()) + .unwrap_or_else(|| prompt_tokens + completion_tokens); + + dspy_rs::Usage { + input_tokens: prompt_tokens, + output_tokens: completion_tokens, + total_tokens, + } +} diff --git a/src/web/agent/tools.rs b/src/web/agent/tools.rs new file mode 100644 index 00000000..f3e6d364 --- /dev/null +++ b/src/web/agent/tools.rs @@ -0,0 +1,1082 @@ +use async_trait::async_trait; +use secp256k1::SecretKey; +use serde_json::json; +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; +use tracing::{error, warn}; +use uuid::Uuid; + +use crate::brave::{BraveClient, SearchOptions}; +use crate::encrypt::{decrypt_string, encrypt_with_key}; +use crate::models::agents::Agent; +use crate::models::conversation_summaries::ConversationSummary; +use crate::models::memory_blocks::{ + MemoryBlock, NewMemoryBlock, MEMORY_BLOCK_LABEL_HUMAN, MEMORY_BLOCK_LABEL_PERSONA, +}; +use crate::models::responses::Conversation; +use crate::models::user_preferences::{ + UserPreference, UserPreferenceError, USER_PREFERENCE_LOCALE, USER_PREFERENCE_TIMEZONE, +}; +use crate::models::users::User; +use crate::rag::{cosine_similarity, deserialize_f32_le, search_user_embeddings}; +use crate::web::openai_auth::AuthMethod; +use crate::{ApiError, AppState}; + +use super::runtime; + +const MAX_CORE_MEMORY_BLOCK_CHARS: usize = 20_000; + +// ============================================================================ +// Shared types +// ============================================================================ + +#[derive(Debug, Clone)] +pub struct ToolResult { + pub success: bool, + pub output: String, + pub error: Option, +} + +impl ToolResult { + pub fn success(output: impl Into) -> Self { + Self { + success: true, + output: output.into(), + error: None, + } + } + + pub fn error(error: impl Into) -> Self { + let msg = error.into(); + Self { + success: false, + output: msg.clone(), + error: Some(msg), + } + } +} + +#[async_trait] +pub trait Tool: Send + Sync { + fn name(&self) -> &str; + fn description(&self) -> &str; + fn args_schema(&self) -> &str; + async fn execute(&self, args: &HashMap) -> ToolResult; +} + +/// Registry of available tools +pub struct ToolRegistry { + tools: BTreeMap>, +} + +impl ToolRegistry { + pub fn new() -> Self { + Self { + tools: BTreeMap::new(), + } + } + + pub fn register(&mut self, tool: Arc) { + self.tools.insert(tool.name().to_string(), tool); + } + + pub fn get(&self, name: &str) -> Option<&Arc> { + self.tools.get(name) + } + + pub fn generate_description(&self) -> String { + if self.tools.is_empty() { + return "No tools available.".to_string(); + } + + let mut desc = String::from("Available tools (add to tool_calls array to use):\n\n"); + for tool in self.tools.values() { + desc.push_str(&format!( + "{}:\n Description: {}\n Args: {}\n\n", + tool.name(), + tool.description(), + tool.args_schema() + )); + } + desc + } +} + +impl Default for ToolRegistry { + fn default() -> Self { + Self::new() + } +} + +// ============================================================================ +// Web search tool +// ============================================================================ + +pub struct WebSearchTool { + client: Arc, +} + +impl WebSearchTool { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +#[async_trait] +impl Tool for WebSearchTool { + fn name(&self) -> &str { + "web_search" + } + + fn description(&self) -> &str { + "Search the web with Brave for current information, news, weather, stocks, sports, and other real-time results. Use 'freshness' for time-sensitive queries and 'location' for local results." + } + + fn args_schema(&self) -> &str { + r#"{"query": "search query", "count": "results (default 10)", "freshness": "pd=24h, pw=week, pm=month, py=year (optional)", "location": "city or 'city, state' for local results (optional)"}"# + } + + async fn execute(&self, args: &HashMap) -> ToolResult { + let Some(query) = args.get("query") else { + return ToolResult::error("'query' argument required"); + }; + + let options = SearchOptions { + count: args.get("count").and_then(|count| count.parse().ok()), + freshness: args.get("freshness").cloned(), + location: args.get("location").cloned(), + timezone: None, + }; + + match self.client.search_with_options(query, Some(options)).await { + Ok(results) => ToolResult::success(results.format_results()), + Err(err) => ToolResult::error(format!("Search failed: {}", err)), + } + } +} + +// ============================================================================ +// Core memory tools +// ============================================================================ + +fn parse_line_arg(args: &HashMap) -> i32 { + args.get("line").and_then(|l| l.parse().ok()).unwrap_or(-1) +} + +fn insert_at_line(value: &str, content: &str, line: i32) -> String { + let lines: Vec<&str> = value.lines().collect(); + let mut new_lines: Vec = lines.iter().map(|s| s.to_string()).collect(); + + let insert_idx = if line < 0 { + new_lines.len() + } else { + (line as usize).min(new_lines.len()) + }; + + new_lines.insert(insert_idx, content.to_string()); + new_lines.join("\n") +} + +fn default_block_value(label: &str) -> Result<&'static str, ApiError> { + match label { + MEMORY_BLOCK_LABEL_PERSONA => Ok(runtime::DEFAULT_PERSONA_VALUE), + MEMORY_BLOCK_LABEL_HUMAN => Ok(""), + _ => Err(ApiError::BadRequest), + } +} + +fn canonical_preference_key(key: &str) -> Result<&'static str, String> { + match key.trim() { + USER_PREFERENCE_TIMEZONE => Ok(USER_PREFERENCE_TIMEZONE), + USER_PREFERENCE_LOCALE | "language" => Ok(USER_PREFERENCE_LOCALE), + "" => Err("'key' must not be empty".to_string()), + other => Err(format!( + "Unsupported preference key '{}'. Known keys: 'timezone' and 'locale'.", + other + )), + } +} + +async fn update_block_value( + state: &Arc, + user_id: Uuid, + user_key: &SecretKey, + label: &str, + new_value: &str, +) -> Result<(), ApiError> { + let _ = default_block_value(label)?; + + if new_value.len() > MAX_CORE_MEMORY_BLOCK_CHARS { + return Err(ApiError::BadRequest); + } + + let value_enc = encrypt_with_key(user_key, new_value.as_bytes()).await; + let new_block = NewMemoryBlock::new(user_id, label, value_enc); + + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + new_block.insert_or_update(&mut conn).map_err(|e| { + error!("Failed to update memory block '{}': {e:?}", label); + ApiError::InternalServerError + })?; + + Ok(()) +} + +pub struct MemoryReplaceTool { + state: Arc, + user_id: Uuid, + user_key: Arc, +} + +impl MemoryReplaceTool { + pub fn new(state: Arc, user_id: Uuid, user_key: Arc) -> Self { + Self { + state, + user_id, + user_key, + } + } +} + +#[async_trait] +impl Tool for MemoryReplaceTool { + fn name(&self) -> &str { + "memory_replace" + } + + fn description(&self) -> &str { + "Replace text in a memory block. Requires exact match of old text." + } + + fn args_schema(&self) -> &str { + r#"{"block": "block label (e.g., 'persona', 'human')", "old": "exact text to find", "new": "replacement text"}"# + } + + async fn execute(&self, args: &HashMap) -> ToolResult { + let Some(block) = args.get("block") else { + return ToolResult::error("'block' argument required"); + }; + let Some(old) = args.get("old") else { + return ToolResult::error("'old' argument required"); + }; + let Some(new) = args.get("new") else { + return ToolResult::error("'new' argument required"); + }; + + let mut conn = match self.state.db.get_pool().get() { + Ok(c) => c, + Err(_) => return ToolResult::error("Database connection error"), + }; + + let existing = match MemoryBlock::get_by_user_and_label(&mut conn, self.user_id, block) { + Ok(existing) => existing, + Err(e) => { + error!("Failed to load block '{}': {e:?}", block); + return ToolResult::error("Failed to load memory block"); + } + }; + + let value = match existing { + Some(existing) => match decrypt_string(&self.user_key, Some(&existing.value_enc)) { + Ok(Some(v)) => v, + Ok(None) => String::new(), + Err(e) => { + error!("Failed to decrypt block '{}': {e:?}", block); + return ToolResult::error("Failed to decrypt memory block"); + } + }, + None => match default_block_value(block) { + Ok(default) => default.to_string(), + Err(_) => return ToolResult::error(format!("Block '{}' not found", block)), + }, + }; + + if !value.contains(old) { + return ToolResult::error(format!( + "Old content '{}' not found in memory block '{}'", + old, block + )); + } + + let new_value = value.replace(old, new); + if let Err(e) = + update_block_value(&self.state, self.user_id, &self.user_key, block, &new_value).await + { + error!("Failed to update block '{}': {e:?}", block); + return ToolResult::error("Failed to update memory block"); + } + + ToolResult::success(format!("Successfully replaced text in '{}' block.", block)) + } +} + +pub struct MemoryAppendTool { + state: Arc, + user_id: Uuid, + user_key: Arc, +} + +impl MemoryAppendTool { + pub fn new(state: Arc, user_id: Uuid, user_key: Arc) -> Self { + Self { + state, + user_id, + user_key, + } + } +} + +#[async_trait] +impl Tool for MemoryAppendTool { + fn name(&self) -> &str { + "memory_append" + } + + fn description(&self) -> &str { + "Append text to the end of a memory block." + } + + fn args_schema(&self) -> &str { + r#"{"block": "block label (e.g., 'persona', 'human')", "content": "text to append"}"# + } + + async fn execute(&self, args: &HashMap) -> ToolResult { + let Some(block) = args.get("block") else { + return ToolResult::error("'block' argument required"); + }; + let Some(content) = args.get("content") else { + return ToolResult::error("'content' argument required"); + }; + + let mut conn = match self.state.db.get_pool().get() { + Ok(c) => c, + Err(_) => return ToolResult::error("Database connection error"), + }; + + let existing = match MemoryBlock::get_by_user_and_label(&mut conn, self.user_id, block) { + Ok(existing) => existing, + Err(e) => { + error!("Failed to load block '{}': {e:?}", block); + return ToolResult::error("Failed to load memory block"); + } + }; + + let value = match existing { + Some(existing) => match decrypt_string(&self.user_key, Some(&existing.value_enc)) { + Ok(Some(v)) => v, + Ok(None) => String::new(), + Err(e) => { + error!("Failed to decrypt block '{}': {e:?}", block); + return ToolResult::error("Failed to decrypt memory block"); + } + }, + None => match default_block_value(block) { + Ok(default) => default.to_string(), + Err(_) => return ToolResult::error(format!("Block '{}' not found", block)), + }, + }; + + let new_value = if value.is_empty() { + content.to_string() + } else { + format!("{}\n{}", value, content) + }; + + if let Err(e) = + update_block_value(&self.state, self.user_id, &self.user_key, block, &new_value).await + { + error!("Failed to update block '{}': {e:?}", block); + return ToolResult::error("Failed to update memory block"); + } + + ToolResult::success(format!("Successfully appended to '{}' block.", block)) + } +} + +pub struct MemoryInsertTool { + state: Arc, + user_id: Uuid, + user_key: Arc, +} + +impl MemoryInsertTool { + pub fn new(state: Arc, user_id: Uuid, user_key: Arc) -> Self { + Self { + state, + user_id, + user_key, + } + } +} + +#[async_trait] +impl Tool for MemoryInsertTool { + fn name(&self) -> &str { + "memory_insert" + } + + fn description(&self) -> &str { + "Insert text at a specific line in a memory block. Use line=-1 for end." + } + + fn args_schema(&self) -> &str { + r#"{"block": "block label", "content": "text to insert", "line": "line number (0-indexed, -1 for end)"}"# + } + + async fn execute(&self, args: &HashMap) -> ToolResult { + let Some(block) = args.get("block") else { + return ToolResult::error("'block' argument required"); + }; + let Some(content) = args.get("content") else { + return ToolResult::error("'content' argument required"); + }; + let line = parse_line_arg(args); + + let mut conn = match self.state.db.get_pool().get() { + Ok(c) => c, + Err(_) => return ToolResult::error("Database connection error"), + }; + + let existing = match MemoryBlock::get_by_user_and_label(&mut conn, self.user_id, block) { + Ok(existing) => existing, + Err(e) => { + error!("Failed to load block '{}': {e:?}", block); + return ToolResult::error("Failed to load memory block"); + } + }; + + let value = match existing { + Some(existing) => match decrypt_string(&self.user_key, Some(&existing.value_enc)) { + Ok(Some(v)) => v, + Ok(None) => String::new(), + Err(e) => { + error!("Failed to decrypt block '{}': {e:?}", block); + return ToolResult::error("Failed to decrypt memory block"); + } + }, + None => match default_block_value(block) { + Ok(default) => default.to_string(), + Err(_) => return ToolResult::error(format!("Block '{}' not found", block)), + }, + }; + + let new_value = insert_at_line(&value, content, line); + + if let Err(e) = + update_block_value(&self.state, self.user_id, &self.user_key, block, &new_value).await + { + error!("Failed to update block '{}': {e:?}", block); + return ToolResult::error("Failed to update memory block"); + } + + ToolResult::success(format!( + "Successfully inserted text into '{}' block at line {}.", + block, + if line < 0 { + "end".to_string() + } else { + line.to_string() + } + )) + } +} + +// ============================================================================ +// Recall + summary search tool +// ============================================================================ + +pub struct ConversationSearchTool { + state: Arc, + user: Arc, + user_key: Arc, + agent_conversation_id: i64, +} + +impl ConversationSearchTool { + pub fn new( + state: Arc, + user: Arc, + user_key: Arc, + conversation_id: i64, + ) -> Self { + Self { + state, + user, + user_key, + agent_conversation_id: conversation_id, + } + } + + async fn search_summaries( + &self, + query: &str, + limit: usize, + ) -> Result, ApiError> { + let (query_vec, _tok) = crate::web::get_embedding_vector( + &self.state, + self.user.as_ref(), + AuthMethod::Jwt, + crate::rag::DEFAULT_EMBEDDING_MODEL, + query, + Some(crate::rag::DEFAULT_EMBEDDING_DIM), + ) + .await?; + + let mut conn = self + .state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + // Fetch latest summaries. Bound this to keep runtime predictable. + let summaries = ConversationSummary::list_for_conversation( + &mut conn, + self.user.uuid, + self.agent_conversation_id, + 200, + ) + .map_err(|e| { + error!("Failed to list conversation summaries: {e:?}"); + ApiError::InternalServerError + })?; + + let mut scored: Vec<(ConversationSummary, f32, String)> = Vec::new(); + for s in summaries { + let Some(embedding_enc) = &s.embedding_enc else { + continue; + }; + let embedding_bytes = crate::encrypt::decrypt_with_key(&self.user_key, embedding_enc) + .map_err(|_| ApiError::InternalServerError)?; + let embedding = deserialize_f32_le(&embedding_bytes)?; + let score = cosine_similarity(&query_vec, &embedding)?; + + let content = decrypt_string(&self.user_key, Some(&s.content_enc)) + .map_err(|_| ApiError::InternalServerError)? + .unwrap_or_default(); + + scored.push((s, score, content)); + } + + scored.sort_by(|a, b| b.1.total_cmp(&a.1)); + scored.truncate(limit); + Ok(scored) + } +} + +#[async_trait] +impl Tool for ConversationSearchTool { + fn name(&self) -> &str { + "conversation_search" + } + + fn description(&self) -> &str { + "Search through past conversation history, including older summarized conversations. Returns matching messages and summaries with relevance scores." + } + + fn args_schema(&self) -> &str { + r#"{"query": "search query", "limit": "max results (default 5)", "conversation_id": "optional conversation UUID to scope message search"}"# + } + + async fn execute(&self, args: &HashMap) -> ToolResult { + let Some(query) = args.get("query") else { + return ToolResult::error("'query' argument required"); + }; + let limit: usize = args.get("limit").and_then(|l| l.parse().ok()).unwrap_or(5); + + let conversation_id_filter: Option = match args.get("conversation_id") { + Some(raw) if !raw.trim().is_empty() => { + let Ok(conversation_uuid) = raw.trim().parse::() else { + return ToolResult::error("invalid 'conversation_id' (expected UUID)"); + }; + + let mut conn = match self.state.db.get_pool().get() { + Ok(c) => c, + Err(_) => return ToolResult::error("database connection error"), + }; + + match Conversation::get_by_uuid_and_user( + &mut conn, + conversation_uuid, + self.user.uuid, + ) { + Ok(c) => Some(c.id), + Err(_) => return ToolResult::error("conversation not found"), + } + } + _ => None, + }; + + let mut output = String::new(); + let mut total_results = 0usize; + + // Search recall messages (all conversations by default) + let source_types = vec![crate::rag::SOURCE_TYPE_MESSAGE.to_string()]; + match search_user_embeddings( + &self.state, + self.user.as_ref(), + AuthMethod::Jwt, + &self.user_key, + query, + limit, + None, + Some(&source_types), + conversation_id_filter, + None, + ) + .await + { + Ok(results) => { + if !results.is_empty() { + total_results += results.len(); + output.push_str(&format!("=== Messages ({}) ===\n\n", results.len())); + for (i, r) in results.iter().enumerate() { + output.push_str(&format!("{}. {}\n\n", i + 1, r.content.trim())); + } + } + } + Err(e) => { + warn!("Message search failed: {e:?}"); + } + } + + // Search summaries + match self.search_summaries(query, limit).await { + Ok(results) => { + if !results.is_empty() { + total_results += results.len(); + output.push_str(&format!( + "=== Conversation Summaries ({}) ===\n\n", + results.len() + )); + for (i, (summary, score, content)) in results.iter().enumerate() { + output.push_str(&format!( + "{}. [Summary of messages {}-{}] (relevance: {:.2})\n{}\n\n", + i + 1, + summary.from_created_at.format("%Y-%m-%d"), + summary.to_created_at.format("%Y-%m-%d"), + *score, + content.trim() + )); + } + } + } + Err(e) => { + warn!("Summary search failed: {e:?}"); + } + } + + if total_results == 0 { + return ToolResult::success("No matching messages or summaries found.".to_string()); + } + + ToolResult::success(output) + } +} + +// ============================================================================ +// Archival tools +// ============================================================================ + +pub struct ArchivalInsertTool { + state: Arc, + user: Arc, + user_key: Arc, +} + +impl ArchivalInsertTool { + pub fn new(state: Arc, user: Arc, user_key: Arc) -> Self { + Self { + state, + user, + user_key, + } + } +} + +#[async_trait] +impl Tool for ArchivalInsertTool { + fn name(&self) -> &str { + "archival_insert" + } + + fn description(&self) -> &str { + "Store information in long-term archival memory for future recall. Good for important facts, preferences, and details you want to remember." + } + + fn args_schema(&self) -> &str { + r#"{"content": "text to store", "tags": "optional comma-separated tags"}"# + } + + async fn execute(&self, args: &HashMap) -> ToolResult { + let Some(content) = args.get("content") else { + return ToolResult::error("'content' argument required"); + }; + + let tags = args.get("tags").map(|t| { + t.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect::>() + }); + + let metadata = tags + .as_ref() + .filter(|t| !t.is_empty()) + .map(|t| json!({"tags": t})); + let metadata_ref = metadata.as_ref(); + + match crate::rag::insert_archival_embedding( + &self.state, + self.user.as_ref(), + AuthMethod::Jwt, + &self.user_key, + content, + metadata_ref, + ) + .await + { + Ok(inserted) => ToolResult::success(format!( + "Successfully stored in archival memory (id: {}).", + inserted.uuid + )), + Err(e) => { + error!("archival_insert failed: {e:?}"); + ToolResult::error("Failed to store in archival memory") + } + } + } +} + +pub struct ArchivalSearchTool { + state: Arc, + user: Arc, + user_key: Arc, +} + +impl ArchivalSearchTool { + pub fn new(state: Arc, user: Arc, user_key: Arc) -> Self { + Self { + state, + user, + user_key, + } + } +} + +#[async_trait] +impl Tool for ArchivalSearchTool { + fn name(&self) -> &str { + "archival_search" + } + + fn description(&self) -> &str { + "Search long-term archival memory using semantic similarity. Returns most relevant stored memories." + } + + fn args_schema(&self) -> &str { + r#"{"query": "search query", "top_k": "max results (default 5)", "tags": "optional comma-separated tags to filter by"}"# + } + + async fn execute(&self, args: &HashMap) -> ToolResult { + let Some(query) = args.get("query") else { + return ToolResult::error("'query' argument required"); + }; + let top_k: usize = args.get("top_k").and_then(|k| k.parse().ok()).unwrap_or(5); + + let tags = args + .get("tags") + .map(|t| { + t.split(',') + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()) + .collect::>() + }) + .filter(|t| !t.is_empty()); + + let source_types = vec![crate::rag::SOURCE_TYPE_ARCHIVAL.to_string()]; + match search_user_embeddings( + &self.state, + self.user.as_ref(), + AuthMethod::Jwt, + &self.user_key, + query, + top_k, + None, + Some(&source_types), + None, + tags.as_deref(), + ) + .await + { + Ok(results) => { + if results.is_empty() { + return ToolResult::success("No matching memories found.".to_string()); + } + + let mut output = format!("Found {} matching memories:\n\n", results.len()); + for (i, r) in results.iter().enumerate() { + output.push_str(&format!("{}. {}\n\n", i + 1, r.content.trim())); + } + ToolResult::success(output) + } + Err(e) => { + error!("archival_search failed: {e:?}"); + ToolResult::error("Archival search failed") + } + } + } +} + +// ============================================================================ +// User preference tool +// ============================================================================ + +pub struct SetPreferenceTool { + state: Arc, + user_id: Uuid, + user_key: Arc, +} + +impl SetPreferenceTool { + pub fn new(state: Arc, user_id: Uuid, user_key: Arc) -> Self { + Self { + state, + user_id, + user_key, + } + } +} + +#[async_trait] +impl Tool for SetPreferenceTool { + fn name(&self) -> &str { + "set_preference" + } + + fn description(&self) -> &str { + "Set a validated user-global preference. Known keys: 'timezone' (IANA timezone like 'America/Chicago') and 'locale' (locale/language hint like 'en' or 'en-US'). Use this only for durable preferences the user explicitly shares or asks to change." + } + + fn args_schema(&self) -> &str { + r#"{"key": "preference key ('timezone' or 'locale')", "value": "preference value"}"# + } + + async fn execute(&self, args: &HashMap) -> ToolResult { + let Some(key) = args.get("key") else { + return ToolResult::error("'key' argument required"); + }; + let resolved_key = match canonical_preference_key(key) { + Ok(key) => key, + Err(err) => return ToolResult::error(err), + }; + + let Some(value) = args.get("value") else { + return ToolResult::error("'value' argument required"); + }; + let Some(value) = runtime::normalize_optional_preference(Some(value.as_str())) else { + return ToolResult::error("'value' must not be empty"); + }; + + if let Err(err) = UserPreference::validate(resolved_key, &value) { + return ToolResult::error(match err { + UserPreferenceError::InvalidPreference(msg) => msg, + UserPreferenceError::DatabaseError(db_err) => { + error!("set_preference validation failed unexpectedly: {db_err:?}"); + "Preference validation failed".to_string() + } + }); + } + + let mut conn = match self.state.db.get_pool().get() { + Ok(c) => c, + Err(_) => return ToolResult::error("Database connection error"), + }; + + match runtime::upsert_user_preference( + &mut conn, + self.user_key.as_ref(), + self.user_id, + resolved_key, + Some(value.clone()), + ) + .await + { + Ok(()) => { + if resolved_key == USER_PREFERENCE_TIMEZONE { + ToolResult::success(format!( + "Set user preference '{}' to '{}'. Follow-user schedules now use this timezone.", + resolved_key, value + )) + } else { + ToolResult::success(format!( + "Set user preference '{}' to '{}'.", + resolved_key, value + )) + } + } + Err(err) => { + error!("set_preference failed for user {}: {err:?}", self.user_id); + ToolResult::error("Failed to update preference") + } + } + } +} + +// ============================================================================ +// Subagent tool +// ============================================================================ + +pub struct SpawnSubagentTool { + state: Arc, + user: Arc, + user_key: Arc, + parent_agent: Agent, +} + +impl SpawnSubagentTool { + pub fn new( + state: Arc, + user: Arc, + user_key: Arc, + parent_agent: Agent, + ) -> Self { + Self { + state, + user, + user_key, + parent_agent, + } + } +} + +#[async_trait] +impl Tool for SpawnSubagentTool { + fn name(&self) -> &str { + "spawn_subagent" + } + + fn description(&self) -> &str { + "Create a focused subagent chat with its own conversation history but shared memory. Use when the user would benefit from a dedicated long-lived workspace for a topic or task." + } + + fn args_schema(&self) -> &str { + r#"{"purpose": "why this subagent exists", "display_name": "optional short name for the subagent"}"# + } + + async fn execute(&self, args: &HashMap) -> ToolResult { + let Some(purpose) = args.get("purpose") else { + return ToolResult::error("'purpose' argument required"); + }; + + if purpose.trim().is_empty() { + return ToolResult::error("'purpose' must not be empty"); + } + + let display_name = args + .get("display_name") + .map(|s| s.trim()) + .filter(|s| !s.is_empty()); + + let mut conn = match self.state.db.get_pool().get() { + Ok(c) => c, + Err(_) => return ToolResult::error("Database connection error"), + }; + + match runtime::create_subagent( + &mut conn, + &self.user_key, + self.user.uuid, + &self.parent_agent, + display_name, + purpose.trim(), + crate::models::agents::AGENT_CREATED_BY_AGENT, + ) + .await + { + Ok((agent, conversation, resolved_display_name)) => ToolResult::success(format!( + "Created subagent '{}' (agent_id: {}, conversation_id: {}).", + resolved_display_name, agent.uuid, conversation.uuid + )), + Err(e) => { + error!("spawn_subagent failed: {e:?}"); + ToolResult::error("Failed to create subagent") + } + } + } +} + +// ============================================================================ +// Done tool +// ============================================================================ + +pub struct DoneTool; + +#[async_trait] +impl Tool for DoneTool { + fn name(&self) -> &str { + "done" + } + + fn description(&self) -> &str { + "No-op signal. Use ONLY when messages is [] AND no other tools needed. Indicates nothing to do this turn." + } + + fn args_schema(&self) -> &str { + r#"{}"# + } + + async fn execute(&self, _args: &HashMap) -> ToolResult { + ToolResult::success("Done.".to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn insert_at_line_end() { + let v = "a\nb"; + assert_eq!(insert_at_line(v, "c", -1), "a\nb\nc"); + } + + #[test] + fn insert_at_line_middle() { + let v = "a\nc"; + assert_eq!(insert_at_line(v, "b", 1), "a\nb\nc"); + } + + #[test] + fn canonical_preference_key_supports_maple_keys() { + assert_eq!( + canonical_preference_key(" timezone ").unwrap(), + USER_PREFERENCE_TIMEZONE + ); + assert_eq!( + canonical_preference_key("locale").unwrap(), + USER_PREFERENCE_LOCALE + ); + } + + #[test] + fn canonical_preference_key_maps_language_to_locale() { + assert_eq!( + canonical_preference_key("language").unwrap(), + USER_PREFERENCE_LOCALE + ); + } + + #[test] + fn canonical_preference_key_rejects_unknown_keys() { + assert_eq!( + canonical_preference_key("display_name").unwrap_err(), + "Unsupported preference key 'display_name'. Known keys: 'timezone' and 'locale'." + ); + } +} diff --git a/src/web/agent/vision.rs b/src/web/agent/vision.rs new file mode 100644 index 00000000..d0e5832e --- /dev/null +++ b/src/web/agent/vision.rs @@ -0,0 +1,115 @@ +use axum::http::HeaderMap; +use serde_json::{json, Value}; +use std::sync::Arc; +use tracing::{debug, error}; + +use crate::models::users::User; +use crate::web::openai::{get_chat_completion_response, BillingContext, CompletionChunk}; +use crate::web::openai_auth::AuthMethod; +use crate::{ApiError, AppState}; + +const DEFAULT_VISION_MAX_TOKENS: u32 = 2048; + +const VISION_SYSTEM_PROMPT: &str = + "You are an image description agent. Your ONLY job is to describe the \ + image the user sent in extreme detail with as much accuracy as possible. \ + Describe everything you see: objects, people, text, colors, layout, \ + emotions, context, setting, lighting, and any other relevant details. \ + Be thorough but organized. If there is text in the image, transcribe it exactly. \ + Recent conversation context is provided so you can understand what the user \ + might be referring to - use it to make your description more relevant, \ + but your primary job is accurate visual description. \ + Output ONLY the description, nothing else."; + +pub async fn describe_image( + state: &Arc, + user: &User, + auth_method: AuthMethod, + model: &str, + image_url: &str, + user_message: &str, + recent_messages: &str, +) -> Result { + if image_url.trim().is_empty() { + return Err(ApiError::BadRequest); + } + + let mut text_parts: Vec = Vec::new(); + if !recent_messages.trim().is_empty() { + text_parts.push(format!( + "Recent conversation for context:\n{}", + recent_messages.trim() + )); + } + if !user_message.trim().is_empty() { + text_parts.push(format!( + "The user sent this message alongside the image: \"{}\"", + user_message.trim() + )); + } + text_parts.push("Describe this image in detail.".to_string()); + + let user_content: Vec = vec![ + json!({ + "type": "image_url", + "image_url": { "url": image_url } + }), + json!({ + "type": "text", + "text": text_parts.join("\n\n") + }), + ]; + + let body = json!({ + "model": model, + "stream": false, + "messages": [ + { "role": "system", "content": VISION_SYSTEM_PROMPT }, + { "role": "user", "content": user_content } + ], + "max_tokens": DEFAULT_VISION_MAX_TOKENS, + }); + + debug!("Vision pre-processing: calling model {}", model); + + let headers = HeaderMap::new(); + let billing_context = BillingContext::new(auth_method, model.to_string()); + let completion = get_chat_completion_response(state, user, body, &headers, billing_context) + .await + .map_err(|e| { + error!("Vision model call failed: {e:?}"); + e + })?; + + if completion.metadata.is_streaming { + error!("Vision pre-processing returned streaming response unexpectedly"); + return Err(ApiError::InternalServerError); + } + + let mut rx = completion.stream; + let response_json = match rx.recv().await { + Some(CompletionChunk::FullResponse(v)) => v, + Some(CompletionChunk::Error(msg)) => { + error!("Vision model error: {}", msg); + return Err(ApiError::InternalServerError); + } + other => { + error!("Unexpected vision completion chunk: {:?}", other); + return Err(ApiError::InternalServerError); + } + }; + + Ok(extract_assistant_content(&response_json) + .unwrap_or_else(|| "[Could not describe image]".to_string())) +} + +fn extract_assistant_content(response_json: &Value) -> Option { + response_json + .get("choices") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|c| c.get("message")) + .and_then(|m| m.get("content")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} diff --git a/src/web/encryption_middleware.rs b/src/web/encryption_middleware.rs index 37e21d95..62941bf2 100644 --- a/src/web/encryption_middleware.rs +++ b/src/web/encryption_middleware.rs @@ -46,7 +46,6 @@ pub async fn decrypt_request( where T: DeserializeOwned + Send + Sync + Clone + 'static, { - tracing::debug!("Entering decrypt_request"); let session_id = headers .get("x-session-id") .and_then(|v| v.to_str().ok()) @@ -85,8 +84,6 @@ where request.extensions_mut().insert(decrypted); request.extensions_mut().insert(session_id); - - tracing::debug!("Exiting decrypt_request"); Ok(next.run(request).await) } @@ -95,12 +92,10 @@ pub async fn encrypt_response( session_id: &Uuid, response: &T, ) -> Result>, ApiError> { - tracing::debug!("Entering encrypt_response"); let response_json = serde_json::to_vec(response).map_err(|_| ApiError::InternalServerError)?; let encrypted_response = state .encrypt_session_data(session_id, &response_json) .await?; - tracing::debug!("Exiting encrypt_response"); Ok(Json(EncryptedResponse::new( base64::engine::general_purpose::STANDARD.encode(encrypted_response), ))) diff --git a/src/web/login_routes.rs b/src/web/login_routes.rs index 7b14d2be..a624fabb 100644 --- a/src/web/login_routes.rs +++ b/src/web/login_routes.rs @@ -2,8 +2,9 @@ use crate::User; use crate::{ db::DBError, email::{send_hello_email, send_verification_email}, - jwt::{validate_token, NewToken, TokenType}, + jwt::{validate_token, CustomClaims, NewToken, TokenType}, models::email_verification::NewEmailVerification, + models::push_devices::PushDevice, }; use crate::{jwt::USER_REFRESH, web::encryption_middleware::EncryptedResponse}; use crate::{ @@ -129,6 +130,8 @@ pub struct RefreshResponse { #[derive(Deserialize, Clone)] pub struct LogoutRequest { refresh_token: String, + #[serde(default)] + push_device_id: Option, } pub async fn login( @@ -240,16 +243,48 @@ pub async fn logout( debug!("Entering logout function"); info!("Logout request received"); // TODO actually delete the refresh token - tracing::trace!( - "Logout request for refresh token: {}", - logout_request.refresh_token - ); + + if let Some((user_id, push_device_id)) = logout_push_revoke_target( + &logout_request, + validate_token(&logout_request.refresh_token, &data, USER_REFRESH), + ) { + match data.db.get_pool().get() { + Ok(mut conn) => { + if let Err(revoke_error) = + PushDevice::revoke_by_uuid_and_user(&mut conn, push_device_id, user_id) + { + error!( + "Failed to revoke push device during logout cleanup: {:?}", + revoke_error + ); + } + } + Err(pool_error) => { + error!( + "Failed to get DB connection during logout push cleanup: {:?}", + pool_error + ); + } + } + } + + tracing::trace!("Logout request received"); let response = json!({ "message": "Logged out successfully" }); let result = encrypt_response(&data, &session_id, &response).await; debug!("Exiting logout function"); result } +fn logout_push_revoke_target( + logout_request: &LogoutRequest, + claims: Result, +) -> Option<(Uuid, Uuid)> { + let push_device_id = logout_request.push_device_id?; + let claims = claims.ok()?; + let user_id = Uuid::parse_str(&claims.sub).ok()?; + Some((user_id, push_device_id)) +} + pub async fn register( State(data): State>, Extension(creds): Extension, @@ -513,3 +548,62 @@ pub async fn password_reset_confirm( debug!("Exiting password_reset_confirm function"); result } + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_claims(user_id: Uuid) -> CustomClaims { + CustomClaims { + sub: user_id.to_string(), + aud: Some(USER_REFRESH.to_string()), + azp: None, + role: None, + } + } + + #[test] + fn logout_push_revoke_target_requires_push_device_id() { + let request = LogoutRequest { + refresh_token: "refresh-token".to_string(), + push_device_id: None, + }; + + assert_eq!( + logout_push_revoke_target(&request, Ok(sample_claims(Uuid::new_v4()))), + None + ); + } + + #[test] + fn logout_push_revoke_target_rejects_invalid_user_id() { + let push_device_id = Uuid::new_v4(); + let request = LogoutRequest { + refresh_token: "refresh-token".to_string(), + push_device_id: Some(push_device_id), + }; + let claims = CustomClaims { + sub: "not-a-uuid".to_string(), + aud: Some(USER_REFRESH.to_string()), + azp: None, + role: None, + }; + + assert_eq!(logout_push_revoke_target(&request, Ok(claims)), None); + } + + #[test] + fn logout_push_revoke_target_returns_user_and_device_ids() { + let user_id = Uuid::new_v4(); + let push_device_id = Uuid::new_v4(); + let request = LogoutRequest { + refresh_token: "refresh-token".to_string(), + push_device_id: Some(push_device_id), + }; + + assert_eq!( + logout_push_revoke_target(&request, Ok(sample_claims(user_id))), + Some((user_id, push_device_id)) + ); + } +} diff --git a/src/web/mod.rs b/src/web/mod.rs index af0d75d5..f7c96e9d 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -1,3 +1,4 @@ +pub mod agent; pub mod attestation_routes; pub mod audio_utils; pub mod documents; @@ -9,15 +10,21 @@ mod openai; pub mod openai_auth; pub mod platform; pub mod protected_routes; +pub mod push; +pub mod rag; pub mod responses; +pub use agent::router as agent_routes; pub use documents::router as document_routes; pub use health_routes::router_with_state as health_routes_with_state; pub use login_routes::router as login_routes; pub use oauth_routes::router as oauth_routes; +pub use openai::get_embedding_vector; pub use openai::router as openai_routes; pub use platform::router as platform_routes; pub use protected_routes::router as protected_routes; +pub use push::router as push_routes; +pub use rag::router as rag_routes; pub use responses::conversations_router as conversations_routes; pub use responses::instructions_router as instructions_routes; pub use responses::responses_router as responses_routes; diff --git a/src/web/openai.rs b/src/web/openai.rs index 89c1e18a..5b084874 100644 --- a/src/web/openai.rs +++ b/src/web/openai.rs @@ -1558,55 +1558,10 @@ async fn proxy_tts( encrypt_response(&state, &session_id, &audio_response).await } -async fn proxy_embeddings( - State(state): State>, - _headers: HeaderMap, - axum::Extension(session_id): axum::Extension, - axum::Extension(user): axum::Extension, - axum::Extension(_auth_method): axum::Extension, - axum::Extension(embedding_request): axum::Extension, -) -> Result>, ApiError> { - debug!("Entering proxy_embeddings function"); - - // Check if guest user is allowed (paid guests are allowed, free guests are not) - if user.is_guest() { - if let Some(billing_client) = &state.billing_client { - match billing_client.is_user_paid(user.uuid).await { - Ok(true) => { - debug!("Paid guest user allowed for embeddings: {}", user.uuid); - } - Ok(false) => { - error!( - "Free guest user attempted to use embeddings feature: {}", - user.uuid - ); - return Err(ApiError::Unauthorized); - } - Err(e) => { - error!("Billing check failed for guest user {}: {}", user.uuid, e); - return Err(ApiError::Unauthorized); - } - } - } else { - error!( - "Guest user attempted to use embeddings without billing client: {}", - user.uuid - ); - return Err(ApiError::Unauthorized); - } - } - - // Validate input is not empty - let is_empty = match &embedding_request.input { - Value::String(s) => s.trim().is_empty(), - Value::Array(arr) => arr.is_empty(), - _ => true, - }; - if is_empty { - error!("Input is empty or invalid"); - return Err(ApiError::BadRequest); - } - +async fn request_embeddings_from_primary( + state: &AppState, + embedding_request: &EmbeddingRequest, +) -> Result<(Value, String), ApiError> { // Get the model route configuration let route = match state.proxy_router.get_model_route(&embedding_request.model) { Some(r) => r, @@ -1626,8 +1581,15 @@ async fn proxy_embeddings( .pool_max_idle_per_host(10) .build::<_, HyperBody>(https); + // Translate model name for the provider (if needed) + let provider_model_name = state + .proxy_router + .get_model_name_for_provider(&embedding_request.model, &route.primary.provider_name); + let mut provider_request = embedding_request.clone(); + provider_request.model = provider_model_name; + // Build request body - let request_body = serde_json::to_string(&embedding_request).map_err(|e| { + let request_body = serde_json::to_string(&provider_request).map_err(|e| { error!("Failed to serialize embedding request: {:?}", e); ApiError::InternalServerError })?; @@ -1692,31 +1654,143 @@ async fn proxy_embeddings( ApiError::InternalServerError })?; + Ok((response_json, route.primary.provider_name)) +} + +async fn request_embeddings_with_billing( + state: &Arc, + user: &User, + auth_method: AuthMethod, + embedding_request: &EmbeddingRequest, +) -> Result<(Value, String, i32), ApiError> { + // Check if guest user is allowed (paid guests are allowed, free guests are not) + if user.is_guest() { + if let Some(billing_client) = &state.billing_client { + match billing_client.is_user_paid(user.uuid).await { + Ok(true) => { + debug!("Paid guest user allowed for embeddings: {}", user.uuid); + } + Ok(false) => { + error!( + "Free guest user attempted to use embeddings feature: {}", + user.uuid + ); + return Err(ApiError::Unauthorized); + } + Err(e) => { + error!("Billing check failed for guest user {}: {}", user.uuid, e); + return Err(ApiError::Unauthorized); + } + } + } else { + error!( + "Guest user attempted to use embeddings without billing client: {}", + user.uuid + ); + return Err(ApiError::Unauthorized); + } + } + + let (response_json, provider_name) = + request_embeddings_from_primary(state, embedding_request).await?; + + let prompt_tokens: i32 = response_json + .get("usage") + .and_then(|u| u.get("prompt_tokens")) + .and_then(|v| v.as_i64()) + .unwrap_or(0) as i32; + // Handle billing - embeddings only have prompt_tokens (no completion_tokens) - if let Some(usage) = response_json.get("usage") { - let prompt_tokens = usage - .get("prompt_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0) as i32; - - if prompt_tokens > 0 { - let billing_context = - BillingContext::new(_auth_method, embedding_request.model.clone()); - let embedding_usage = CompletionUsage { - prompt_tokens, - completion_tokens: 0, // Embeddings don't have completion tokens - }; - publish_usage_event_internal( - &state, - &user, - &billing_context, - embedding_usage, - &route.primary.provider_name, - ) - .await; + if prompt_tokens > 0 { + let billing_context = BillingContext::new(auth_method, embedding_request.model.clone()); + let embedding_usage = CompletionUsage { + prompt_tokens, + completion_tokens: 0, + }; + publish_usage_event_internal( + state, + user, + &billing_context, + embedding_usage, + &provider_name, + ) + .await; + } + + Ok((response_json, provider_name, prompt_tokens)) +} + +pub async fn get_embedding_vector( + state: &Arc, + user: &User, + auth_method: AuthMethod, + model: &str, + input: &str, + dimensions: Option, +) -> Result<(Vec, i32), ApiError> { + let request = EmbeddingRequest { + input: Value::String(input.to_string()), + model: model.to_string(), + encoding_format: Some("float".to_string()), + dimensions, + user: None, + }; + + let (response_json, _provider, prompt_tokens) = + request_embeddings_with_billing(state, user, auth_method, &request).await?; + + let embedding = response_json + .get("data") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|obj| obj.get("embedding")) + .and_then(|v| v.as_array()) + .ok_or(ApiError::InternalServerError)?; + + let mut vector: Vec = Vec::with_capacity(embedding.len()); + for v in embedding { + let f = v.as_f64().ok_or(ApiError::InternalServerError)?; + vector.push(f as f32); + } + + if let Some(dim) = dimensions { + if vector.len() != dim as usize { + error!( + "Embeddings response dim mismatch: expected={}, got={}", + dim, + vector.len() + ); + return Err(ApiError::InternalServerError); } } + Ok((vector, prompt_tokens)) +} + +async fn proxy_embeddings( + State(state): State>, + _headers: HeaderMap, + axum::Extension(session_id): axum::Extension, + axum::Extension(user): axum::Extension, + axum::Extension(auth_method): axum::Extension, + axum::Extension(embedding_request): axum::Extension, +) -> Result>, ApiError> { + debug!("Entering proxy_embeddings function"); + + // Validate input is not empty + let is_empty = match &embedding_request.input { + Value::String(s) => s.trim().is_empty(), + Value::Array(arr) => arr.is_empty(), + _ => true, + }; + if is_empty { + error!("Input is empty or invalid"); + return Err(ApiError::BadRequest); + } + + let (response_json, _provider_name, _prompt_tokens) = + request_embeddings_with_billing(&state, &user, auth_method, &embedding_request).await?; + debug!("Exiting proxy_embeddings function"); // Encrypt and return the response diff --git a/src/web/platform/common.rs b/src/web/platform/common.rs index b6f33f00..759bf079 100644 --- a/src/web/platform/common.rs +++ b/src/web/platform/common.rs @@ -1,7 +1,9 @@ use crate::{ models::{ org_memberships::OrgRole, - project_settings::{AppleOAuthSettings, OAuthProviderSettings}, + project_settings::{ + AndroidPushSettings, AppleOAuthSettings, IosPushSettings, OAuthProviderSettings, + }, }, web::platform::validation::{ validate_alphanumeric_only, validate_alphanumeric_with_symbols, validate_secret_size, @@ -16,6 +18,8 @@ pub const PROJECT_RESEND_API_KEY: &str = "RESEND_API_KEY"; pub const PROJECT_GOOGLE_OAUTH_SECRET: &str = "GOOGLE_OAUTH_SECRET"; pub const PROJECT_GITHUB_OAUTH_SECRET: &str = "GITHUB_OAUTH_SECRET"; pub const PROJECT_APPLE_OAUTH_SECRET: &str = "APPLE_OAUTH_SECRET"; +pub const PROJECT_APNS_AUTH_KEY_P8: &str = "APNS_AUTH_KEY_P8"; +pub const PROJECT_FCM_SERVICE_ACCOUNT_JSON: &str = "FCM_SERVICE_ACCOUNT_JSON"; pub const THIRD_PARTY_JWT_SECRET: &str = "THIRD_PARTY_JWT_SECRET"; // Request Types @@ -98,6 +102,16 @@ pub struct UpdateOAuthSettingsRequest { pub apple_oauth_settings: Option, } +#[derive(Deserialize, Clone)] +pub struct UpdatePushSettingsRequest { + #[serde(default)] + pub encrypted_preview_enabled: bool, + #[serde(default)] + pub ios: Option, + #[serde(default)] + pub android: Option, +} + // Response Types #[derive(Serialize)] pub struct OrgResponse { diff --git a/src/web/platform/mod.rs b/src/web/platform/mod.rs index dac2a8a2..a452dde9 100644 --- a/src/web/platform/mod.rs +++ b/src/web/platform/mod.rs @@ -11,7 +11,8 @@ pub use login_routes::router as login_routes; // Re-export constants for backward compatibility pub use common::{ - PROJECT_GITHUB_OAUTH_SECRET, PROJECT_GOOGLE_OAUTH_SECRET, PROJECT_RESEND_API_KEY, + PROJECT_APNS_AUTH_KEY_P8, PROJECT_FCM_SERVICE_ACCOUNT_JSON, PROJECT_GITHUB_OAUTH_SECRET, + PROJECT_GOOGLE_OAUTH_SECRET, PROJECT_RESEND_API_KEY, }; // Export the composite router diff --git a/src/web/platform/project_routes.rs b/src/web/platform/project_routes.rs index fb41d740..bff3b5d4 100644 --- a/src/web/platform/project_routes.rs +++ b/src/web/platform/project_routes.rs @@ -4,7 +4,7 @@ use crate::{ org_project_secrets::NewOrgProjectSecret, org_projects::NewOrgProject, platform_users::PlatformUser, - project_settings::{EmailSettings, OAuthSettings}, + project_settings::{EmailSettings, OAuthSettings, PushSettings}, }, web::encryption_middleware::{decrypt_request, encrypt_response, EncryptedResponse}, ApiError, AppState, @@ -27,6 +27,7 @@ use validator::Validate; use super::common::{ CreateProjectRequest, CreateSecretRequest, ProjectResponse, SecretResponse, UpdateEmailSettingsRequest, UpdateOAuthSettingsRequest, UpdateProjectRequest, + UpdatePushSettingsRequest, }; pub fn router(app_state: Arc) -> Router { @@ -100,6 +101,18 @@ pub fn router(app_state: Arc) -> Router { decrypt_request::, )), ) + .route( + "/platform/orgs/:org_id/projects/:project_id/settings/push", + get(get_push_settings) + .layer(from_fn_with_state(app_state.clone(), decrypt_request::<()>)), + ) + .route( + "/platform/orgs/:org_id/projects/:project_id/settings/push", + put(update_push_settings).layer(from_fn_with_state( + app_state.clone(), + decrypt_request::, + )), + ) .with_state(app_state) } @@ -815,3 +828,124 @@ async fn update_oauth_settings( encrypt_response(&data, &session_id, &updated_settings).await } + +async fn get_push_settings( + State(data): State>, + Extension(platform_user): Extension, + Path((org_id, project_id)): Path<(Uuid, Uuid)>, + Extension(session_id): Extension, +) -> Result>, ApiError> { + debug!("Getting project push settings"); + + let org = data + .db + .get_org_by_uuid(org_id) + .map_err(|_| ApiError::NotFound)?; + + let project = data.db.get_org_project_by_uuid(project_id).map_err(|_| { + error!("Project not found"); + ApiError::NotFound + })?; + + if project.org_id != org.id { + error!("Project does not belong to organization"); + return Err(ApiError::NotFound); + } + + let _membership = data + .db + .get_org_membership_by_platform_user_and_org(platform_user.uuid, org.id) + .map_err(|_| ApiError::Unauthorized)?; + + let settings = data + .db + .get_project_push_settings(project.id)? + .unwrap_or_default(); + + encrypt_response(&data, &session_id, &settings).await +} + +async fn update_push_settings( + State(data): State>, + Extension(platform_user): Extension, + Path((org_id, project_id)): Path<(Uuid, Uuid)>, + Extension(update_request): Extension, + Extension(session_id): Extension, +) -> Result>, ApiError> { + debug!("Updating project push settings"); + + validate_push_settings_request(&update_request)?; + + let org = data + .db + .get_org_by_uuid(org_id) + .map_err(|_| ApiError::NotFound)?; + + let project = data.db.get_org_project_by_uuid(project_id).map_err(|_| { + error!("Project not found"); + ApiError::NotFound + })?; + + if project.org_id != org.id { + error!("Project does not belong to organization"); + return Err(ApiError::NotFound); + } + + let membership = data + .db + .get_org_membership_by_platform_user_and_org(platform_user.uuid, org.id) + .map_err(|_| ApiError::Unauthorized)?; + + let role: OrgRole = membership.role.into(); + if !matches!(role, OrgRole::Owner | OrgRole::Admin) { + return Err(ApiError::Unauthorized); + } + + let push_settings = PushSettings { + encrypted_preview_enabled: update_request.encrypted_preview_enabled, + ios: update_request.ios, + android: update_request.android, + }; + + let settings = data + .db + .update_project_push_settings(project.id, push_settings)?; + + let updated_settings = settings.get_push_settings().map_err(|e| { + error!("Failed to parse updated push settings: {:?}", e); + ApiError::InternalServerError + })?; + + encrypt_response(&data, &session_id, &updated_settings).await +} + +fn validate_push_settings_request(request: &UpdatePushSettingsRequest) -> Result<(), ApiError> { + if let Some(ios) = &request.ios { + if ios.enabled + && (ios.bundle_id.trim().is_empty() + || ios.team_id.trim().is_empty() + || ios.key_id.trim().is_empty()) + { + return Err(ApiError::BadRequest); + } + + if ios.bundle_id.len() > 255 || ios.team_id.len() > 32 || ios.key_id.len() > 32 { + return Err(ApiError::BadRequest); + } + } + + if let Some(android) = &request.android { + if android.enabled + && (android.firebase_project_id.trim().is_empty() + || android.package_name.trim().is_empty()) + { + return Err(ApiError::BadRequest); + } + + if android.firebase_project_id.len() > 255 || android.package_name.len() > 255 { + return Err(ApiError::BadRequest); + } + } + + Ok(()) +} diff --git a/src/web/push.rs b/src/web/push.rs new file mode 100644 index 00000000..99396ad6 --- /dev/null +++ b/src/web/push.rs @@ -0,0 +1,476 @@ +use crate::encrypt::encrypt_with_key; +use crate::models::push_devices::{ + NewPushDevice, PushDevice, PushDeviceError, PUSH_ENV_DEV, PUSH_ENV_PROD, + PUSH_KEY_ALGORITHM_P256_ECDH_V1, PUSH_PLATFORM_ANDROID, PUSH_PLATFORM_IOS, PUSH_PROVIDER_APNS, + PUSH_PROVIDER_FCM, +}; +use crate::models::users::User; +use crate::web::encryption_middleware::{decrypt_request, encrypt_response, EncryptedResponse}; +use crate::{ApiError, AppState}; +use axum::{ + extract::{Path, State}, + middleware::from_fn_with_state, + routing::{delete, get, post}, + Extension, Json, Router, +}; +use base64::{engine::general_purpose, Engine as _}; +use chrono::{DateTime, Utc}; +use diesel::Connection; +use p256::pkcs8::DecodePublicKey; +use p256::PublicKey; +use secp256k1::SecretKey; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::sync::Arc; +use tracing::error; +use uuid::Uuid; + +#[derive(Debug, Clone, Deserialize)] +struct RegisterPushDeviceRequest { + installation_id: Uuid, + platform: String, + provider: String, + environment: String, + app_id: String, + push_token: String, + notification_public_key: String, + key_algorithm: String, + #[serde(default)] + supports_encrypted_preview: bool, + #[serde(default)] + supports_background_processing: bool, +} + +#[derive(Debug, Clone, Serialize)] +struct PushDeviceResponse { + id: Uuid, + object: &'static str, + installation_id: Uuid, + platform: String, + provider: String, + environment: String, + app_id: String, + key_algorithm: String, + supports_encrypted_preview: bool, + supports_background_processing: bool, + last_seen_at: DateTime, + created_at: DateTime, + updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize)] +struct PushDeviceListResponse { + object: &'static str, + data: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct DeletedPushDeviceResponse { + id: Uuid, + object: &'static str, + deleted: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RegistrationWriteMode { + ReuseExisting, + InsertNew, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RegistrationDecision { + write_mode: RegistrationWriteMode, + revoke_device_ids: Vec, +} + +pub fn router(app_state: Arc) -> Router { + Router::new() + .route( + "/v1/push/devices", + post(register_push_device).layer(from_fn_with_state( + app_state.clone(), + decrypt_request::, + )), + ) + .route( + "/v1/push/devices", + get(list_push_devices) + .layer(from_fn_with_state(app_state.clone(), decrypt_request::<()>)), + ) + .route( + "/v1/push/devices/:id", + delete(revoke_push_device) + .layer(from_fn_with_state(app_state.clone(), decrypt_request::<()>)), + ) + .with_state(app_state) +} + +async fn register_push_device( + State(state): State>, + Extension(user): Extension, + Extension(body): Extension, + Extension(session_id): Extension, +) -> Result>, ApiError> { + let public_key_bytes = general_purpose::STANDARD + .decode(&body.notification_public_key) + .map_err(|_| ApiError::BadRequest)?; + validate_register_request(&body, &public_key_bytes)?; + + let token_hash = Sha256::digest(body.push_token.as_bytes()).to_vec(); + let enclave_key = + SecretKey::from_slice(&state.enclave_key).map_err(|_| ApiError::InternalServerError)?; + let push_token_enc = encrypt_with_key(&enclave_key, body.push_token.as_bytes()).await; + + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + let now = Utc::now(); + let device = conn + .transaction::(|conn| { + let existing_for_user = PushDevice::get_by_installation_for_user( + conn, + user.uuid, + body.installation_id, + &body.environment, + )?; + let active_installation = + PushDevice::get_by_installation(conn, body.installation_id, &body.environment)?; + let conflicting_token_owner = PushDevice::get_active_by_token_hash( + conn, + &body.provider, + &body.environment, + &token_hash, + )?; + + let decision = plan_push_device_registration( + user.uuid, + existing_for_user.as_ref(), + active_installation.as_ref(), + conflicting_token_owner.as_ref(), + ); + + for revoke_device_id in decision.revoke_device_ids { + PushDevice::revoke_by_id(conn, revoke_device_id)?; + } + + if decision.write_mode == RegistrationWriteMode::ReuseExisting { + let mut existing = existing_for_user.expect("existing device should be present"); + existing.platform = body.platform.clone(); + existing.provider = body.provider.clone(); + existing.environment = body.environment.clone(); + existing.app_id = body.app_id.clone(); + existing.push_token_enc = push_token_enc.clone(); + existing.push_token_hash = token_hash.clone(); + existing.notification_public_key = public_key_bytes.clone(); + existing.key_algorithm = body.key_algorithm.clone(); + existing.supports_encrypted_preview = body.supports_encrypted_preview; + existing.supports_background_processing = body.supports_background_processing; + existing.last_seen_at = now; + existing.revoked_at = None; + existing.update(conn)?; + Ok(existing) + } else { + NewPushDevice { + user_id: user.uuid, + installation_id: body.installation_id, + platform: body.platform.clone(), + provider: body.provider.clone(), + environment: body.environment.clone(), + app_id: body.app_id.clone(), + push_token_enc: push_token_enc.clone(), + push_token_hash: token_hash.clone(), + notification_public_key: public_key_bytes.clone(), + key_algorithm: body.key_algorithm.clone(), + supports_encrypted_preview: body.supports_encrypted_preview, + supports_background_processing: body.supports_background_processing, + last_seen_at: now, + } + .insert(conn) + } + }) + .map_err(map_push_device_error)?; + + encrypt_response(&state, &session_id, &PushDeviceResponse::from(device)).await +} + +async fn list_push_devices( + State(state): State>, + Extension(user): Extension, + Extension(session_id): Extension, +) -> Result>, ApiError> { + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + let devices = + PushDevice::list_active_for_user(&mut conn, user.uuid).map_err(map_push_device_error)?; + let response = PushDeviceListResponse { + object: "list", + data: devices.into_iter().map(PushDeviceResponse::from).collect(), + }; + + encrypt_response(&state, &session_id, &response).await +} + +async fn revoke_push_device( + State(state): State>, + Path(device_uuid): Path, + Extension(user): Extension, + Extension(session_id): Extension, +) -> Result>, ApiError> { + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + let device = PushDevice::revoke_by_uuid_and_user(&mut conn, device_uuid, user.uuid) + .map_err(map_push_device_error)? + .ok_or(ApiError::NotFound)?; + + let response = DeletedPushDeviceResponse { + id: device.uuid, + object: "push.device.deleted", + deleted: true, + }; + + encrypt_response(&state, &session_id, &response).await +} + +fn validate_register_request( + request: &RegisterPushDeviceRequest, + public_key_bytes: &[u8], +) -> Result<(), ApiError> { + if request.installation_id.is_nil() || request.installation_id.get_version_num() != 4 { + return Err(ApiError::BadRequest); + } + + if request.push_token.trim().is_empty() + || request.app_id.trim().is_empty() + || request.notification_public_key.trim().is_empty() + { + return Err(ApiError::BadRequest); + } + + if request.app_id.len() > 255 || request.push_token.len() > 4096 { + return Err(ApiError::BadRequest); + } + + let valid_platform_provider = matches!( + (request.platform.as_str(), request.provider.as_str()), + (PUSH_PLATFORM_IOS, PUSH_PROVIDER_APNS) | (PUSH_PLATFORM_ANDROID, PUSH_PROVIDER_FCM) + ); + + if !valid_platform_provider { + return Err(ApiError::BadRequest); + } + + if request.environment != PUSH_ENV_DEV && request.environment != PUSH_ENV_PROD { + return Err(ApiError::BadRequest); + } + + if request.key_algorithm != PUSH_KEY_ALGORITHM_P256_ECDH_V1 { + return Err(ApiError::BadRequest); + } + + if request.notification_public_key.len() > 1024 || public_key_bytes.len() > 256 { + return Err(ApiError::BadRequest); + } + + PublicKey::from_public_key_der(public_key_bytes).map_err(|_| ApiError::BadRequest)?; + + Ok(()) +} + +fn map_push_device_error(error: PushDeviceError) -> ApiError { + error!("Push device database error: {:?}", error); + ApiError::InternalServerError +} + +fn plan_push_device_registration( + current_user_id: Uuid, + existing_for_user: Option<&PushDevice>, + active_installation: Option<&PushDevice>, + conflicting_token_owner: Option<&PushDevice>, +) -> RegistrationDecision { + let write_mode = if existing_for_user.is_some() { + RegistrationWriteMode::ReuseExisting + } else { + RegistrationWriteMode::InsertNew + }; + + let existing_device_id = existing_for_user.map(|device| device.id); + let mut revoke_device_ids = Vec::new(); + + if let Some(active_device) = active_installation { + if active_device.user_id != current_user_id && existing_device_id != Some(active_device.id) + { + revoke_device_ids.push(active_device.id); + } + } + + if let Some(conflict) = conflicting_token_owner { + if existing_device_id != Some(conflict.id) && !revoke_device_ids.contains(&conflict.id) { + revoke_device_ids.push(conflict.id); + } + } + + RegistrationDecision { + write_mode, + revoke_device_ids, + } +} + +impl From for PushDeviceResponse { + fn from(value: PushDevice) -> Self { + Self { + id: value.uuid, + object: "push.device", + installation_id: value.installation_id, + platform: value.platform, + provider: value.provider, + environment: value.environment, + app_id: value.app_id, + key_algorithm: value.key_algorithm, + supports_encrypted_preview: value.supports_encrypted_preview, + supports_background_processing: value.supports_background_processing, + last_seen_at: value.last_seen_at, + created_at: value.created_at, + updated_at: value.updated_at, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::engine::general_purpose; + use p256::pkcs8::EncodePublicKey; + use rand_core::OsRng; + + fn sample_push_device( + user_id: Uuid, + installation_id: Uuid, + token_hash: Vec, + revoked_at: Option>, + ) -> PushDevice { + let now = Utc::now(); + PushDevice { + id: now.timestamp_nanos_opt().expect("valid timestamp nanos"), + uuid: Uuid::new_v4(), + user_id, + installation_id, + platform: PUSH_PLATFORM_IOS.to_string(), + provider: PUSH_PROVIDER_APNS.to_string(), + environment: PUSH_ENV_PROD.to_string(), + app_id: "ai.trymaple.ios".to_string(), + push_token_enc: vec![1, 2, 3], + push_token_hash: token_hash, + notification_public_key: vec![4, 5, 6], + key_algorithm: PUSH_KEY_ALGORITHM_P256_ECDH_V1.to_string(), + supports_encrypted_preview: true, + supports_background_processing: true, + last_seen_at: now, + revoked_at, + created_at: now, + updated_at: now, + } + } + + fn sample_register_request(installation_id: Uuid) -> RegisterPushDeviceRequest { + let secret_key = p256::SecretKey::random(&mut OsRng); + let public_key = secret_key.public_key(); + let public_key_der = public_key + .to_public_key_der() + .expect("public key should encode"); + + RegisterPushDeviceRequest { + installation_id, + platform: PUSH_PLATFORM_IOS.to_string(), + provider: PUSH_PROVIDER_APNS.to_string(), + environment: PUSH_ENV_PROD.to_string(), + app_id: "ai.trymaple.ios".to_string(), + push_token: "push-token".to_string(), + notification_public_key: general_purpose::STANDARD.encode(public_key_der.as_bytes()), + key_algorithm: PUSH_KEY_ALGORITHM_P256_ECDH_V1.to_string(), + supports_encrypted_preview: true, + supports_background_processing: true, + } + } + + #[test] + fn plan_reuses_revoked_same_user_installation() { + let user_id = Uuid::new_v4(); + let installation_id = Uuid::new_v4(); + let existing = sample_push_device(user_id, installation_id, vec![1], Some(Utc::now())); + + let decision = plan_push_device_registration(user_id, Some(&existing), None, None); + + assert_eq!(decision.write_mode, RegistrationWriteMode::ReuseExisting); + assert!(decision.revoke_device_ids.is_empty()); + } + + #[test] + fn plan_revokes_cross_user_installation_once() { + let current_user_id = Uuid::new_v4(); + let installation_id = Uuid::new_v4(); + let other_user_device = + sample_push_device(Uuid::new_v4(), installation_id, vec![1, 2, 3], None); + + let decision = plan_push_device_registration( + current_user_id, + None, + Some(&other_user_device), + Some(&other_user_device), + ); + + assert_eq!(decision.write_mode, RegistrationWriteMode::InsertNew); + assert_eq!(decision.revoke_device_ids, vec![other_user_device.id]); + } + + #[test] + fn plan_revokes_conflicting_token_owner_for_handoff() { + let current_user_id = Uuid::new_v4(); + let conflicting_device = + sample_push_device(Uuid::new_v4(), Uuid::new_v4(), vec![9, 9, 9], None); + + let decision = + plan_push_device_registration(current_user_id, None, None, Some(&conflicting_device)); + + assert_eq!(decision.write_mode, RegistrationWriteMode::InsertNew); + assert_eq!(decision.revoke_device_ids, vec![conflicting_device.id]); + } + + #[test] + fn validate_register_request_rejects_nil_installation_id() { + let request = sample_register_request(Uuid::nil()); + let public_key_bytes = general_purpose::STANDARD + .decode(&request.notification_public_key) + .expect("public key should decode"); + + assert!(matches!( + validate_register_request(&request, &public_key_bytes), + Err(ApiError::BadRequest) + )); + } + + #[test] + fn validate_register_request_rejects_non_v4_installation_id() { + let request = sample_register_request( + Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").expect("uuid should parse"), + ); + let public_key_bytes = general_purpose::STANDARD + .decode(&request.notification_public_key) + .expect("public key should decode"); + + assert!(matches!( + validate_register_request(&request, &public_key_bytes), + Err(ApiError::BadRequest) + )); + } +} diff --git a/src/web/rag.rs b/src/web/rag.rs new file mode 100644 index 00000000..7efe32d3 --- /dev/null +++ b/src/web/rag.rs @@ -0,0 +1,213 @@ +use axum::{ + extract::{Path, State}, + middleware::from_fn_with_state, + routing::{delete, get, post}, + Extension, Json, Router, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::sync::Arc; +use uuid::Uuid; + +use crate::models::users::User; +use crate::rag; +use crate::web::encryption_middleware::{decrypt_request, encrypt_response, EncryptedResponse}; +use crate::web::openai_auth::AuthMethod; +use crate::web::responses::error_mapping; +use crate::{ApiError, AppMode, AppState}; + +#[derive(Debug, Clone, Deserialize)] +struct InsertEmbeddingRequest { + text: String, + metadata: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct InsertEmbeddingResponse { + id: Uuid, + source_type: String, + embedding_model: String, + token_count: i32, + created_at: DateTime, +} + +#[derive(Debug, Clone, Deserialize)] +struct SearchRequest { + query: String, + top_k: Option, + max_tokens: Option, + source_types: Option>, + conversation_id: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct SearchResponse { + results: Vec, +} + +pub fn router(app_state: Arc) -> Router<()> { + // Experimental endpoints: only enabled in Local/Dev. + if !matches!(app_state.app_mode, AppMode::Local | AppMode::Dev) { + return Router::new().with_state(app_state); + } + + Router::new() + .route( + "/v1/rag/embeddings", + post(insert_archival_embedding).layer(from_fn_with_state( + app_state.clone(), + decrypt_request::, + )), + ) + .route( + "/v1/rag/embeddings", + delete(delete_all_embeddings) + .layer(from_fn_with_state(app_state.clone(), decrypt_request::<()>)), + ) + .route( + "/v1/rag/embeddings/:id", + delete(delete_embedding) + .layer(from_fn_with_state(app_state.clone(), decrypt_request::<()>)), + ) + .route( + "/v1/rag/search", + post(search).layer(from_fn_with_state( + app_state.clone(), + decrypt_request::, + )), + ) + .route( + "/v1/rag/embeddings/status", + get(status).layer(from_fn_with_state(app_state.clone(), decrypt_request::<()>)), + ) + .with_state(app_state) +} + +async fn insert_archival_embedding( + State(state): State>, + Extension(session_id): Extension, + Extension(user): Extension, + Extension(body): Extension, +) -> Result { + if body.text.trim().is_empty() { + return Err(ApiError::BadRequest); + } + if let Some(m) = &body.metadata { + if !m.is_object() { + return Err(ApiError::BadRequest); + } + } + + let user_key = state + .get_user_key(user.uuid, None, None) + .await + .map_err(|_| error_mapping::map_key_retrieval_error())?; + + let inserted = rag::insert_archival_embedding( + &state, + &user, + AuthMethod::Jwt, + &user_key, + &body.text, + body.metadata.as_ref(), + ) + .await?; + + let response = InsertEmbeddingResponse { + id: inserted.uuid, + source_type: inserted.source_type, + embedding_model: inserted.embedding_model, + token_count: inserted.token_count, + created_at: inserted.created_at, + }; + + let encrypted = encrypt_response(&state, &session_id, &response).await?; + Ok((axum::http::StatusCode::CREATED, encrypted)) +} + +async fn search( + State(state): State>, + Extension(session_id): Extension, + Extension(user): Extension, + Extension(body): Extension, +) -> Result>, ApiError> { + if body.query.trim().is_empty() { + return Err(ApiError::BadRequest); + } + + let top_k = body.top_k.unwrap_or(5); + if top_k == 0 || top_k > 20 { + return Err(ApiError::BadRequest); + } + + if let Some(max_tokens) = body.max_tokens { + if max_tokens <= 0 { + return Err(ApiError::BadRequest); + } + } + + if let Some(source_types) = &body.source_types { + if source_types.is_empty() { + return Err(ApiError::BadRequest); + } + } + + let conversation_internal_id = if let Some(conversation_uuid) = body.conversation_id { + let conversation = state + .db + .get_conversation_by_uuid_and_user(conversation_uuid, user.uuid) + .map_err(error_mapping::map_conversation_error)?; + Some(conversation.id) + } else { + None + }; + + let user_key = state + .get_user_key(user.uuid, None, None) + .await + .map_err(|_| error_mapping::map_key_retrieval_error())?; + + let results = rag::search_user_embeddings( + &state, + &user, + AuthMethod::Jwt, + &user_key, + &body.query, + top_k, + body.max_tokens, + body.source_types.as_deref(), + conversation_internal_id, + None, + ) + .await?; + + let response = SearchResponse { results }; + encrypt_response(&state, &session_id, &response).await +} + +async fn delete_all_embeddings( + State(state): State>, + Extension(user): Extension, +) -> Result { + rag::delete_all_user_embeddings(&state, user.uuid).await?; + Ok(axum::http::StatusCode::NO_CONTENT) +} + +async fn delete_embedding( + State(state): State>, + Path(embedding_id): Path, + Extension(user): Extension, +) -> Result { + rag::delete_user_embedding_by_uuid(&state, user.uuid, embedding_id).await?; + Ok(axum::http::StatusCode::NO_CONTENT) +} + +async fn status( + State(state): State>, + Extension(session_id): Extension, + Extension(user): Extension, +) -> Result>, ApiError> { + let response = rag::embeddings_status(&state, user.uuid).await?; + encrypt_response(&state, &session_id, &response).await +} diff --git a/src/web/responses/conversations.rs b/src/web/responses/conversations.rs index d3be4843..1b297220 100644 --- a/src/web/responses/conversations.rs +++ b/src/web/responses/conversations.rs @@ -3,6 +3,7 @@ use crate::{ encrypt::{decrypt_content, encrypt_with_key}, + models::agents::Agent, models::responses::NewConversation, models::users::User, web::{ @@ -69,6 +70,21 @@ impl ConversationContext { .get_conversation_by_uuid_and_user(conversation_id, user_uuid) .map_err(error_mapping::map_conversation_error)?; + // Hide the main agent conversation from the public Conversations API. + let agent_conversation_id = { + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + main_agent_conversation_id(&mut conn, user_uuid)? + }; + + if Some(conversation.id) == agent_conversation_id { + return Err(ApiError::NotFound); + } + // Get user's encryption key let user_key = state .get_user_key(user_uuid, None, None) @@ -94,6 +110,15 @@ impl ConversationContext { } } +fn main_agent_conversation_id( + conn: &mut diesel::PgConnection, + user_uuid: Uuid, +) -> Result, ApiError> { + Agent::get_main_for_user(conn, user_uuid) + .map_err(|_| ApiError::InternalServerError) + .map(|agent| agent.map(|a| a.conversation_id)) +} + // ============================================================================ // Request/Response Types // ============================================================================ @@ -545,11 +570,22 @@ async fn list_conversations( params.limit.min(MAX_PAGINATION_LIMIT) }; + let agent_conversation_id = { + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + main_agent_conversation_id(&mut conn, user.uuid)? + }; + // Fetch conversations with database-level pagination // We fetch limit + 1 to check if there are more results let conversations = state .db - .list_conversations(user.uuid, limit + 1, params.after, ¶ms.order) + // Fetch an extra conversation since we may filter out the hidden agent thread. + .list_conversations(user.uuid, limit + 2, params.after, ¶ms.order) .map_err(error_mapping::map_generic_db_error)?; trace!( @@ -561,14 +597,18 @@ async fn list_conversations( params.order ); + let filtered: Vec<_> = conversations + .into_iter() + .filter(|c| Some(c.id) != agent_conversation_id) + .collect(); + // Check if there are more results - let has_more = conversations.len() > limit as usize; + let has_more = filtered.len() > limit as usize; - // Take only the requested limit - let conversations_to_return = if has_more { - &conversations[..limit as usize] + let conversations_to_return: Vec<_> = if has_more { + filtered.into_iter().take(limit as usize).collect() } else { - &conversations[..] + filtered }; // Get user's encryption key for decrypting metadata @@ -595,7 +635,7 @@ async fn list_conversations( .collect::, _>>()?; // Extract cursor IDs - let (first_id, last_id) = Paginator::get_cursor_ids(conversations_to_return, |c| c.uuid); + let (first_id, last_id) = Paginator::get_cursor_ids(&conversations_to_return, |c| c.uuid); let response = ConversationListResponse { object: OBJECT_TYPE_LIST, @@ -618,10 +658,31 @@ async fn delete_all_conversations( ) -> Result>, ApiError> { debug!("Deleting all conversations for user {}", user.uuid); - state + let mut conn = state .db - .delete_all_conversations(user.uuid) - .map_err(error_mapping::map_generic_db_error)?; + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + let agent_conversation_id = main_agent_conversation_id(&mut conn, user.uuid)?; + + if let Some(agent_conversation_id) = agent_conversation_id { + use crate::models::schema::conversations::dsl::*; + use diesel::prelude::*; + + diesel::delete( + conversations + .filter(user_id.eq(user.uuid)) + .filter(id.ne(agent_conversation_id)), + ) + .execute(&mut conn) + .map_err(|_| ApiError::InternalServerError)?; + } else { + state + .db + .delete_all_conversations(user.uuid) + .map_err(error_mapping::map_generic_db_error)?; + } let response = json!({ "object": OBJECT_TYPE_LIST_DELETED, @@ -654,6 +715,16 @@ async fn batch_delete_conversations( let mut results = Vec::with_capacity(body.ids.len()); + let agent_conversation_id = { + let mut conn = state + .db + .get_pool() + .get() + .map_err(|_| ApiError::InternalServerError)?; + + main_agent_conversation_id(&mut conn, user.uuid)? + }; + for conversation_id in body.ids { // Try to get the conversation first to verify ownership match state @@ -661,6 +732,16 @@ async fn batch_delete_conversations( .get_conversation_by_uuid_and_user(conversation_id, user.uuid) { Ok(conversation) => { + if Some(conversation.id) == agent_conversation_id { + results.push(BatchDeleteItemResult { + id: conversation_id, + object: constants::OBJECT_TYPE_CONVERSATION_DELETED, + deleted: false, + error: Some("not_found"), + }); + continue; + } + // Delete the conversation match state.db.delete_conversation(conversation.id, user.uuid) { Ok(()) => { diff --git a/src/web/responses/conversions.rs b/src/web/responses/conversions.rs index 8fdfe6c9..69be5229 100644 --- a/src/web/responses/conversions.rs +++ b/src/web/responses/conversions.rs @@ -190,6 +190,8 @@ pub enum ConversationItem { role: String, content: Vec, #[serde(skip_serializing_if = "Option::is_none")] + reaction: Option, + #[serde(skip_serializing_if = "Option::is_none")] created_at: Option, }, #[serde(rename = "function_call")] @@ -292,6 +294,7 @@ impl ConversationItemConverter { status: msg.status.clone(), role: ROLE_USER.to_string(), content: Vec::::from(message_content), + reaction: msg.reaction.clone(), created_at: Some(msg.created_at.timestamp()), }) } @@ -314,6 +317,7 @@ impl ConversationItemConverter { status: msg.status.clone(), role: ROLE_ASSISTANT.to_string(), content: content_parts, + reaction: msg.reaction.clone(), created_at: Some(msg.created_at.timestamp()), }) } diff --git a/src/web/responses/handlers.rs b/src/web/responses/handlers.rs index eba3b4d2..45a7d72d 100644 --- a/src/web/responses/handlers.rs +++ b/src/web/responses/handlers.rs @@ -1259,7 +1259,9 @@ async fn persist_request_data( response_id: Some(response.id), user_id: user.uuid, content_enc: prepared.content_enc.clone(), + attachment_text_enc: None, prompt_tokens: prepared.user_message_tokens, + assistant_reaction: None, }; let user_message = state .db @@ -1653,6 +1655,7 @@ async fn setup_completion_processor( completion_tokens: 0, status: STATUS_IN_PROGRESS.to_string(), finish_reason: None, + user_reaction: None, }; state .db