From 8cf1171db62a615a42435f1fec6bc3cdc9345366 Mon Sep 17 00:00:00 2001 From: Che <30403707+Che-Zhu@users.noreply.github.com> Date: Wed, 20 May 2026 14:29:09 +0800 Subject: [PATCH 1/4] refactor: run brain deployments embedded --- Cargo.lock | 630 +-------------------------------- Cargo.toml | 1 - README.md | 36 +- README_zh.md | 36 +- src/{ => brain}/deployments.rs | 157 ++++---- src/brain/mod.rs | 1 + src/config.rs | 135 +------ src/devbox.rs | 4 - src/env_config.rs | 26 -- src/error.rs | 4 - src/lib.rs | 4 +- src/main.rs | 84 ++--- src/session_manager.rs | 239 +++---------- tests/http_integration.rs | 33 +- 14 files changed, 217 insertions(+), 1173 deletions(-) rename src/{ => brain}/deployments.rs (80%) create mode 100644 src/brain/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 03db960..8acaaaf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -150,12 +150,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - [[package]] name = "chrono" version = "0.4.44" @@ -179,7 +173,6 @@ dependencies = [ "chrono", "futures-util", "jsonwebtoken", - "reqwest", "serde", "serde_json", "thiserror", @@ -195,17 +188,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -301,20 +283,6 @@ 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", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", -] - [[package]] name = "getrandom" version = "0.4.2" @@ -323,7 +291,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi 6.0.0", + "r-efi", "wasip2", "wasip3", ] @@ -412,23 +380,6 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots", ] [[package]] @@ -437,21 +388,13 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", "bytes", - "futures-channel", - "futures-util", "http", "http-body", "hyper", - "ipnet", - "libc", - "percent-encoding", "pin-project-lite", - "socket2", "tokio", "tower-service", - "tracing", ] [[package]] @@ -478,115 +421,12 @@ dependencies = [ "cc", ] -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - [[package]] name = "indexmap" version = "2.14.0" @@ -599,12 +439,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - [[package]] name = "itoa" version = "1.0.18" @@ -617,8 +451,6 @@ version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ - "cfg-if", - "futures-util", "once_cell", "wasm-bindgen", ] @@ -654,24 +486,12 @@ version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "matchers" version = "0.2.0" @@ -746,24 +566,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "prettyplease" version = "0.2.37" @@ -783,61 +585,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" -dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.52.0", -] - [[package]] name = "quote" version = "1.0.45" @@ -847,47 +594,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 = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[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", -] - -[[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 = "regex-automata" version = "0.4.14" @@ -905,44 +617,6 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64", - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots", -] - [[package]] name = "ring" version = "0.17.14" @@ -957,47 +631,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rustc-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" - -[[package]] -name = "rustls" -version = "0.23.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" -dependencies = [ - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustversion" version = "1.0.22" @@ -1129,18 +762,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" version = "2.0.117" @@ -1157,20 +778,6 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] [[package]] name = "thiserror" @@ -1201,38 +808,12 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.51.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" dependencies = [ - "bytes", "libc", "mio", "pin-project-lite", @@ -1253,16 +834,6 @@ dependencies = [ "syn", ] -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - [[package]] name = "tower" version = "0.5.3" @@ -1278,24 +849,6 @@ dependencies = [ "tower-service", ] -[[package]] -name = "tower-http" -version = "0.6.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" -dependencies = [ - "bitflags", - "bytes", - "futures-util", - "http", - "http-body", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", - "url", -] - [[package]] name = "tower-layer" version = "0.3.3" @@ -1369,12 +922,6 @@ dependencies = [ "tracing-log", ] -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -1393,24 +940,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - [[package]] name = "uuid" version = "1.23.0" @@ -1429,15 +958,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1475,16 +995,6 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.67" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "wasm-bindgen-macro" version = "0.2.117" @@ -1551,35 +1061,6 @@ dependencies = [ "semver", ] -[[package]] -name = "web-sys" -version = "0.3.94" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" -dependencies = [ - "js-sys", - "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 = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "windows-core" version = "0.62.2" @@ -1809,115 +1290,6 @@ dependencies = [ "wasmparser", ] -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "yoke" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 6fcc99e..529f74e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,6 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } uuid = { version = "1", features = ["serde", "v4"] } jsonwebtoken = { version = "9", default-features = false } -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } [profile.release] opt-level = "z" diff --git a/README.md b/README.md index 78ce286..50b51b8 100644 --- a/README.md +++ b/README.md @@ -8,27 +8,22 @@ Generated Qoder documentation may exist under `.qoder/`; it is generated output ## Runtime Shape -The default runtime is `embedded`: +All Gateway sessions use the embedded runtime: 1. A client creates a session through the Rust gateway. 2. The session owns a `CodexAppServerBridge`. 3. The bridge starts and manages one `codex app-server` subprocess over stdio. 4. App-server notifications are folded into session state and streamed to the client over SSE. -The optional `devbox` runtime is a remote execution backend: +Brain deployment tasks use the same embedded runtime, but expose a polling-only task API instead of the interactive session API. Gateway does not create or manage Devbox runtimes for deployment requests. -1. The outer gateway creates a Devbox runtime. -2. It waits for the gateway inside Devbox to become ready. -3. It creates a remote session in that inner gateway. -4. The inner gateway uses the `embedded` runtime to run `codex app-server`. - -`devbox` is runtime infrastructure, not a product mode. +If deployment work runs in Devbox, an external system is responsible for creating the Devbox and starting this gateway inside it before calling the Brain Deployment API. ## Brain Deployment API -`POST /api/deployments` is a Brain application reserved API. It is not intended to describe a general deployment product surface. +`POST /api/brain/deployments` is a Brain application API. It is not intended to describe a general deployment product surface. -The endpoint creates a Codex task that deploys a repository and reports a machine-readable deployment result. When the active session runtime is Devbox-backed, Gateway bootstraps the Devbox runtime before starting the Brain deployment task. +The endpoint creates a local embedded Codex task that installs the deployment skill if needed, builds the repository image, pushes it to GHCR, and reports a machine-readable deployment result. It does not expose intermediate Codex output or accept follow-up user turns. ## HTTP API @@ -44,8 +39,8 @@ The endpoint creates a Codex task that deploys a repository and reports a machin - `DELETE /api/sessions/:id` - `GET /api/threads` - `GET /api/threads/:threadId` -- `POST /api/deployments` -- `GET /api/deployments/:threadId` +- `POST /api/brain/deployments` +- `GET /api/brain/deployments/:threadId` Legacy single-session routes such as `/api/state`, `/api/events`, `/api/turn`, and `/api/thread/new` are removed and return `410 Gone`. @@ -89,21 +84,8 @@ Gateway-owned settings use the `CODEX_GATEWAY_` prefix. - `CODEX_GATEWAY_MAX_SESSIONS`: maximum live sessions. Defaults to `12`. - `CODEX_GATEWAY_SESSION_TTL_MS`: idle session TTL. Defaults to `1800000`. - `CODEX_GATEWAY_SESSION_SWEEP_INTERVAL_MS`: cleanup sweep interval. Defaults to `60000`. -- `CODEX_GATEWAY_SESSION_RUNTIME`: session runtime backend. Defaults to `embedded`. Supported values are `embedded` and `devbox`. -- `CODEX_GATEWAY_MAX_DEPLOYMENTS`: maximum active Brain deployment tasks. Defaults to `4`. -- `CODEX_GATEWAY_DEPLOYMENT_TIMEOUT_MS`: Brain deployment timeout and session keepalive window. Defaults to `3600000`. - -Devbox-related settings are only used when the runtime is `devbox`: - -- `CODEX_GATEWAY_DEVBOX_BASE_URL` -- `CODEX_GATEWAY_DEVBOX_TOKEN` -- `CODEX_GATEWAY_DEVBOX_JWT_SIGNING_KEY` -- `CODEX_GATEWAY_DEVBOX_NAMESPACE` -- `CODEX_GATEWAY_DEVBOX_RUNTIME_IMAGE` -- `CODEX_GATEWAY_DEVBOX_ARCHIVE_AFTER_PAUSE_TIME` -- `CODEX_GATEWAY_DEVBOX_WAIT_TIMEOUT_SECONDS` -- `CODEX_GATEWAY_DEVBOX_GATEWAY_READY_TIMEOUT_SECONDS` -- `CODEX_GATEWAY_DEVBOX_BOOTSTRAP_TIMEOUT_SECONDS` + +Devbox lifecycle is external to this gateway. If the gateway is running in Devbox, configure the process with the normal gateway settings above. ## Verification diff --git a/README_zh.md b/README_zh.md index 02f7630..ff49449 100644 --- a/README_zh.md +++ b/README_zh.md @@ -8,27 +8,22 @@ Codex Gateway 是一个 Rust HTTP/SSE 网关,用来通过小型 API 和浏览 ## Runtime 形态 -默认 runtime 是 `embedded`: +所有 Gateway session 都使用 embedded runtime: 1. 客户端通过 Rust gateway 创建 session。 2. session 拥有一个 `CodexAppServerBridge`。 3. bridge 通过 stdio 启动并管理一个 `codex app-server` 子进程。 4. app-server 的通知会写入 session state,并通过 SSE 推给客户端。 -可选的 `devbox` runtime 是远端执行后端: +Brain deployment task 也使用同一个 embedded runtime,但对外暴露的是 polling-only 的任务 API,而不是可交互 session API。Gateway 收到部署请求时不会创建或管理 Devbox runtime。 -1. 外层 gateway 创建 Devbox runtime。 -2. 外层 gateway 等待 Devbox 内部的 gateway ready。 -3. 外层 gateway 在内部 gateway 里创建远端 session。 -4. 内部 gateway 使用 `embedded` runtime 运行 `codex app-server`。 - -`devbox` 是 runtime 基础设施,不是产品模式。 +如果部署工作运行在 Devbox 中,外部系统负责先创建 Devbox,并在 Devbox 内启动这个 gateway,然后再调用 Brain Deployment API。 ## Brain Deployment API -`POST /api/deployments` 是为 Brain 应用预留的接口,不是通用部署产品接口。 +`POST /api/brain/deployments` 是 Brain 应用接口,不是通用部署产品接口。 -这个接口会创建一个 Codex task,用来部署仓库并返回机器可读的部署结果。当当前 session runtime 由 Devbox 承载时,Gateway 会先 bootstrap Devbox runtime,再启动 Brain deployment task。 +这个接口会创建一个本地 embedded Codex task。该 task 会按需安装 deployment skill,构建仓库镜像,推送到 GHCR,并返回机器可读的部署结果。接口不暴露 Codex 中间输出,也不接受用户继续输入。 ## HTTP API @@ -44,8 +39,8 @@ Codex Gateway 是一个 Rust HTTP/SSE 网关,用来通过小型 API 和浏览 - `DELETE /api/sessions/:id` - `GET /api/threads` - `GET /api/threads/:threadId` -- `POST /api/deployments` -- `GET /api/deployments/:threadId` +- `POST /api/brain/deployments` +- `GET /api/brain/deployments/:threadId` 旧的单 session 路由已经移除,例如 `/api/state`、`/api/events`、`/api/turn`、`/api/thread/new`,现在会返回 `410 Gone`。 @@ -89,21 +84,8 @@ Gateway 自有配置统一使用 `CODEX_GATEWAY_` 前缀。 - `CODEX_GATEWAY_MAX_SESSIONS`:最大在线 session 数,默认 `12` - `CODEX_GATEWAY_SESSION_TTL_MS`:空闲 session TTL,默认 `1800000` - `CODEX_GATEWAY_SESSION_SWEEP_INTERVAL_MS`:清理扫描间隔,默认 `60000` -- `CODEX_GATEWAY_SESSION_RUNTIME`:session runtime backend,默认 `embedded`;支持值只有 `embedded` 和 `devbox` -- `CODEX_GATEWAY_MAX_DEPLOYMENTS`:最大并发 Brain deployment task 数,默认 `4` -- `CODEX_GATEWAY_DEPLOYMENT_TIMEOUT_MS`:Brain deployment 超时和 session keepalive 窗口,默认 `3600000` - -Devbox 相关配置只在 runtime 为 `devbox` 时使用: - -- `CODEX_GATEWAY_DEVBOX_BASE_URL` -- `CODEX_GATEWAY_DEVBOX_TOKEN` -- `CODEX_GATEWAY_DEVBOX_JWT_SIGNING_KEY` -- `CODEX_GATEWAY_DEVBOX_NAMESPACE` -- `CODEX_GATEWAY_DEVBOX_RUNTIME_IMAGE` -- `CODEX_GATEWAY_DEVBOX_ARCHIVE_AFTER_PAUSE_TIME` -- `CODEX_GATEWAY_DEVBOX_WAIT_TIMEOUT_SECONDS` -- `CODEX_GATEWAY_DEVBOX_GATEWAY_READY_TIMEOUT_SECONDS` -- `CODEX_GATEWAY_DEVBOX_BOOTSTRAP_TIMEOUT_SECONDS` + +Devbox 生命周期在 gateway 外部管理。如果 gateway 运行在 Devbox 中,仍然只需要配置上面的常规 gateway 设置。 ## 验证 diff --git a/src/deployments.rs b/src/brain/deployments.rs similarity index 80% rename from src/deployments.rs rename to src/brain/deployments.rs index 2f45657..4eec340 100644 --- a/src/deployments.rs +++ b/src/brain/deployments.rs @@ -9,39 +9,41 @@ use serde_json::Value; use crate::error::AppError; const DEPLOYMENT_RESULT_PREFIX: &str = "DEPLOYMENT_RESULT:"; -const DEPLOYMENT_SKILL_TRIGGER: &str = "/fulling-deploy"; +const BRAIN_DEPLOYMENT_SKILL_TRIGGER: &str = "/fulling-deploy"; +const DEFAULT_BRAIN_MAX_ACTIVE_DEPLOYMENTS: usize = 4; +const DEFAULT_BRAIN_DEPLOYMENT_TIMEOUT: Duration = Duration::from_secs(60 * 60); #[derive(Clone)] -pub struct DeploymentRegistry { - inner: Arc>, +pub struct BrainDeploymentRegistry { + inner: Arc>, max_active: usize, timeout: Duration, } -struct DeploymentRegistryInner { +struct BrainDeploymentRegistryInner { creating: usize, - records: HashMap, + records: HashMap, } #[derive(Debug, Clone)] -pub struct DeploymentRecord { +pub struct BrainDeploymentRecord { pub thread_id: String, pub session_id: String, pub repository: String, pub branch: Option, pub created_at: DateTime, pub expires_at: DateTime, - pub terminal_status: Option, + pub terminal_status: Option, } -pub struct DeploymentCreateGuard { - registry: DeploymentRegistry, +pub struct BrainDeploymentCreateGuard { + registry: BrainDeploymentRegistry, active: bool, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct DeploymentStatusResponse { +pub struct BrainDeploymentStatusResponse { pub thread_id: String, pub status: String, pub message: String, @@ -50,28 +52,28 @@ pub struct DeploymentStatusResponse { } #[derive(Debug, Deserialize)] -struct DeploymentResultLine { +struct BrainDeploymentResultLine { status: String, image: Option, message: Option, error: Option, } -enum DeploymentResultState { - Found(DeploymentResultLine), +enum BrainDeploymentResultState { + Found(BrainDeploymentResultLine), Invalid(String), Missing, } -impl DeploymentRegistry { - pub fn new(max_active: usize, timeout: Duration) -> Self { +impl BrainDeploymentRegistry { + pub fn new() -> Self { Self { - inner: Arc::new(Mutex::new(DeploymentRegistryInner { + inner: Arc::new(Mutex::new(BrainDeploymentRegistryInner { creating: 0, records: HashMap::new(), })), - max_active, - timeout, + max_active: DEFAULT_BRAIN_MAX_ACTIVE_DEPLOYMENTS, + timeout: DEFAULT_BRAIN_DEPLOYMENT_TIMEOUT, } } @@ -79,9 +81,9 @@ impl DeploymentRegistry { self.timeout } - pub fn try_begin_create(&self) -> Result { + pub fn try_begin_create(&self) -> Result { let mut inner = self.inner.lock().unwrap(); - let active = inner.creating + active_deployments(&inner.records); + let active = inner.creating + active_brain_deployments(&inner.records); if active >= self.max_active { return Err(AppError::service_unavailable(format!( "Maximum concurrent deployments reached ({})", @@ -90,13 +92,13 @@ impl DeploymentRegistry { } inner.creating += 1; - Ok(DeploymentCreateGuard { + Ok(BrainDeploymentCreateGuard { registry: self.clone(), active: true, }) } - fn finish_create(&self, record: DeploymentRecord) { + fn finish_create(&self, record: BrainDeploymentRecord) { let mut inner = self.inner.lock().unwrap(); inner.creating = inner.creating.saturating_sub(1); inner.records.insert(record.thread_id.clone(), record); @@ -107,15 +109,15 @@ impl DeploymentRegistry { inner.creating = inner.creating.saturating_sub(1); } - pub fn get(&self, thread_id: &str) -> Option { + pub fn get(&self, thread_id: &str) -> Option { self.inner.lock().unwrap().records.get(thread_id).cloned() } pub fn mark_terminal( &self, thread_id: &str, - response: DeploymentStatusResponse, - ) -> Option { + response: BrainDeploymentStatusResponse, + ) -> Option { let mut inner = self.inner.lock().unwrap(); let record = inner.records.get_mut(thread_id)?; record.terminal_status = Some(response); @@ -123,7 +125,7 @@ impl DeploymentRegistry { } } -impl DeploymentRecord { +impl BrainDeploymentRecord { fn new( thread_id: String, session_id: String, @@ -148,8 +150,8 @@ impl DeploymentRecord { self.terminal_status.is_none() && Utc::now() >= self.expires_at } - pub fn timeout_response(&self) -> DeploymentStatusResponse { - DeploymentStatusResponse { + pub fn timeout_response(&self) -> BrainDeploymentStatusResponse { + BrainDeploymentStatusResponse { thread_id: self.thread_id.clone(), status: "failed".to_string(), message: "Deployment failed".to_string(), @@ -158,8 +160,8 @@ impl DeploymentRecord { } } - pub fn stopped_response(&self) -> DeploymentStatusResponse { - DeploymentStatusResponse { + pub fn stopped_response(&self) -> BrainDeploymentStatusResponse { + BrainDeploymentStatusResponse { thread_id: self.thread_id.clone(), status: "failed".to_string(), message: "Deployment failed".to_string(), @@ -169,16 +171,16 @@ impl DeploymentRecord { } } -impl DeploymentCreateGuard { +impl BrainDeploymentCreateGuard { pub fn complete( mut self, thread_id: String, session_id: String, repository: String, branch: Option, - ) -> DeploymentRecord { + ) -> BrainDeploymentRecord { self.active = false; - let record = DeploymentRecord::new( + let record = BrainDeploymentRecord::new( thread_id, session_id, repository, @@ -190,7 +192,7 @@ impl DeploymentCreateGuard { } } -impl Drop for DeploymentCreateGuard { +impl Drop for BrainDeploymentCreateGuard { fn drop(&mut self) { if self.active { self.registry.cancel_create(); @@ -198,7 +200,7 @@ impl Drop for DeploymentCreateGuard { } } -fn active_deployments(records: &HashMap) -> usize { +fn active_brain_deployments(records: &HashMap) -> usize { let now = Utc::now(); records .values() @@ -206,7 +208,7 @@ fn active_deployments(records: &HashMap) -> usize { .count() } -pub fn build_deployment_prompt( +pub fn build_brain_deployment_prompt( repository: &str, branch: Option<&str>, github_token: &str, @@ -217,7 +219,7 @@ pub fn build_deployment_prompt( .unwrap_or_else(|| "Use the repository default branch.".to_string()); let skill_instruction = if skill_preinstalled { format!( - "The deployment skill has already been installed by the gateway runtime bootstrap. Use the {DEPLOYMENT_SKILL_TRIGGER} deployment workflow if it is available. If it is unavailable, perform the equivalent workflow: inspect the repository, generate or reuse a Dockerfile, verify the image build, publish the image to GHCR, and report the pushed image reference." + "The deployment skill has already been installed by the gateway runtime bootstrap. Use the {BRAIN_DEPLOYMENT_SKILL_TRIGGER} deployment workflow if it is available. If it is unavailable, perform the equivalent workflow: inspect the repository, generate or reuse a Dockerfile, verify the image build, publish the image to GHCR, and report the pushed image reference." ) } else { format!( @@ -226,7 +228,7 @@ pub fn build_deployment_prompt( npx --yes skills add https://github.com/zjy365/seakills/tree/sandbox-skill-lite -y - If the install command fails, stop and return a failed `DEPLOYMENT_RESULT` with the install failure reason. -After the skill is installed, use the {DEPLOYMENT_SKILL_TRIGGER} deployment workflow if it is available. If it is unavailable, perform the equivalent workflow: inspect the repository, generate or reuse a Dockerfile, verify the image build, publish the image to GHCR, and report the pushed image reference."# +After the skill is installed, use the {BRAIN_DEPLOYMENT_SKILL_TRIGGER} deployment workflow if it is available. If it is unavailable, perform the equivalent workflow: inspect the repository, generate or reuse a Dockerfile, verify the image build, publish the image to GHCR, and report the pushed image reference."# ) }; @@ -258,29 +260,33 @@ DEPLOYMENT_RESULT: {{"status":"failed","image":null,"message":"Deployment failed ) } -pub fn deployment_status_from_thread( +pub fn brain_deployment_status_from_thread( thread_id: &str, thread_result: &Value, -) -> DeploymentStatusResponse { +) -> BrainDeploymentStatusResponse { let thread = thread_result.get("thread").unwrap_or(thread_result); - match find_deployment_result(thread) { - DeploymentResultState::Found(result) => response_from_result(thread_id, result), - DeploymentResultState::Invalid(error) => DeploymentStatusResponse { + match find_brain_deployment_result(thread) { + BrainDeploymentResultState::Found(result) => { + brain_deployment_response_from_result(thread_id, result) + } + BrainDeploymentResultState::Invalid(error) => BrainDeploymentStatusResponse { thread_id: thread_id.to_string(), status: "failed".to_string(), message: "Deployment failed".to_string(), image: None, error: Some(error), }, - DeploymentResultState::Missing if thread_is_active(thread) => DeploymentStatusResponse { - thread_id: thread_id.to_string(), - status: "running".to_string(), - message: "Deployment is still running".to_string(), - image: None, - error: None, - }, - DeploymentResultState::Missing => DeploymentStatusResponse { + BrainDeploymentResultState::Missing if thread_is_active(thread) => { + BrainDeploymentStatusResponse { + thread_id: thread_id.to_string(), + status: "running".to_string(), + message: "Deployment is still running".to_string(), + image: None, + error: None, + } + } + BrainDeploymentResultState::Missing => BrainDeploymentStatusResponse { thread_id: thread_id.to_string(), status: "failed".to_string(), message: "Deployment failed".to_string(), @@ -290,12 +296,15 @@ pub fn deployment_status_from_thread( } } -fn response_from_result(thread_id: &str, result: DeploymentResultLine) -> DeploymentStatusResponse { +fn brain_deployment_response_from_result( + thread_id: &str, + result: BrainDeploymentResultLine, +) -> BrainDeploymentStatusResponse { match result.status.trim().to_ascii_lowercase().as_str() { "succeeded" => { let image = trim_optional(result.image); if !image.as_deref().is_some_and(is_valid_ghcr_image) { - return DeploymentStatusResponse { + return BrainDeploymentStatusResponse { thread_id: thread_id.to_string(), status: "failed".to_string(), message: "Deployment failed".to_string(), @@ -304,7 +313,7 @@ fn response_from_result(thread_id: &str, result: DeploymentResultLine) -> Deploy }; } - DeploymentStatusResponse { + BrainDeploymentStatusResponse { thread_id: thread_id.to_string(), status: "succeeded".to_string(), message: trim_optional(result.message) @@ -315,7 +324,7 @@ fn response_from_result(thread_id: &str, result: DeploymentResultLine) -> Deploy } "failed" => { if result.image.is_some() { - return DeploymentStatusResponse { + return BrainDeploymentStatusResponse { thread_id: thread_id.to_string(), status: "failed".to_string(), message: "Deployment failed".to_string(), @@ -324,7 +333,7 @@ fn response_from_result(thread_id: &str, result: DeploymentResultLine) -> Deploy }; } - DeploymentStatusResponse { + BrainDeploymentStatusResponse { thread_id: thread_id.to_string(), status: "failed".to_string(), message: trim_optional(result.message) @@ -334,7 +343,7 @@ fn response_from_result(thread_id: &str, result: DeploymentResultLine) -> Deploy .or_else(|| Some("Deployment failed without an error message".to_string())), } } - other => DeploymentStatusResponse { + other => BrainDeploymentStatusResponse { thread_id: thread_id.to_string(), status: "failed".to_string(), message: "Deployment failed".to_string(), @@ -344,9 +353,9 @@ fn response_from_result(thread_id: &str, result: DeploymentResultLine) -> Deploy } } -fn find_deployment_result(thread: &Value) -> DeploymentResultState { +fn find_brain_deployment_result(thread: &Value) -> BrainDeploymentResultState { let Some(turns) = thread.get("turns").and_then(Value::as_array) else { - return DeploymentResultState::Missing; + return BrainDeploymentResultState::Missing; }; for turn in turns.iter().rev() { @@ -364,16 +373,16 @@ fn find_deployment_result(thread: &Value) -> DeploymentResultState { } for text in agent_message_texts(item).into_iter().rev() { - match parse_result_from_text(&text) { - Ok(Some(result)) => return DeploymentResultState::Found(result), + match parse_brain_deployment_result_from_text(&text) { + Ok(Some(result)) => return BrainDeploymentResultState::Found(result), Ok(None) => {} - Err(error) => return DeploymentResultState::Invalid(error), + Err(error) => return BrainDeploymentResultState::Invalid(error), } } } } - DeploymentResultState::Missing + BrainDeploymentResultState::Missing } fn agent_message_texts(item: &Value) -> Vec { @@ -395,7 +404,9 @@ fn agent_message_texts(item: &Value) -> Vec { texts } -fn parse_result_from_text(text: &str) -> Result, String> { +fn parse_brain_deployment_result_from_text( + text: &str, +) -> Result, String> { let lines = text.lines().collect::>(); for (index, line) in lines.iter().enumerate().rev() { let Some(json_text) = line.strip_prefix(DEPLOYMENT_RESULT_PREFIX) else { @@ -500,7 +511,7 @@ mod tests { #[test] fn deployment_prompt_includes_exact_result_shapes() { - let prompt = build_deployment_prompt("owner/repo", Some("main"), "ghp_secret", false); + let prompt = build_brain_deployment_prompt("owner/repo", Some("main"), "ghp_secret", false); assert!(prompt.contains("Repository: owner/repo")); assert!(prompt.contains("Use branch `main`.")); @@ -521,7 +532,7 @@ mod tests { #[test] fn deployment_prompt_skips_skill_install_when_runtime_bootstrapped() { - let prompt = build_deployment_prompt("owner/repo", None, "ghp_secret", true); + let prompt = build_brain_deployment_prompt("owner/repo", None, "ghp_secret", true); assert!(prompt.contains("deployment skill has already been installed")); assert!(!prompt.contains("npx --yes skills add")); @@ -556,7 +567,7 @@ mod tests { } }); - let status = deployment_status_from_thread("thread-1", &thread); + let status = brain_deployment_status_from_thread("thread-1", &thread); assert_eq!(status.status, "succeeded"); assert_eq!( @@ -574,7 +585,7 @@ mod tests { } }); - let status = deployment_status_from_thread("thread-1", &thread); + let status = brain_deployment_status_from_thread("thread-1", &thread); assert_eq!(status.status, "running"); assert_eq!(status.image, None); @@ -597,7 +608,7 @@ mod tests { } }); - let status = deployment_status_from_thread("thread-1", &thread); + let status = brain_deployment_status_from_thread("thread-1", &thread); assert_eq!(status.status, "failed"); assert!(status.error.as_deref().unwrap_or("").contains("not found")); @@ -622,7 +633,7 @@ mod tests { } }); - let status = deployment_status_from_thread("thread-1", &thread); + let status = brain_deployment_status_from_thread("thread-1", &thread); assert_eq!(status.status, "failed"); assert!(status.error.as_deref().unwrap_or("").contains("GHCR image")); @@ -647,7 +658,7 @@ mod tests { } }); - let status = deployment_status_from_thread("thread-1", &thread); + let status = brain_deployment_status_from_thread("thread-1", &thread); assert_eq!(status.status, "failed"); assert!(status.error.as_deref().unwrap_or("").contains("GHCR image")); @@ -681,7 +692,7 @@ mod tests { } }); - let status = deployment_status_from_thread("thread-1", &thread); + let status = brain_deployment_status_from_thread("thread-1", &thread); assert_eq!(status.status, "failed"); assert!( @@ -712,7 +723,7 @@ mod tests { } }); - let status = deployment_status_from_thread("thread-1", &thread); + let status = brain_deployment_status_from_thread("thread-1", &thread); assert_eq!(status.status, "failed"); assert!(status.error.as_deref().unwrap_or("").contains("final")); diff --git a/src/brain/mod.rs b/src/brain/mod.rs new file mode 100644 index 0000000..d048287 --- /dev/null +++ b/src/brain/mod.rs @@ -0,0 +1 @@ +pub mod deployments; diff --git a/src/config.rs b/src/config.rs index d0efb33..4ec5f2a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,16 +2,10 @@ use std::path::PathBuf; use std::time::Duration; use crate::env_config::{ - BRIDGE_CWD_ENV, CODEX_BIN_ENV, DEBUG_ENV, DEFAULT_MODEL_ENV, DEPLOYMENT_TIMEOUT_MS_ENV, - DEVBOX_ARCHIVE_AFTER_PAUSE_TIME_ENV, DEVBOX_BASE_URL_ENV, DEVBOX_BOOTSTRAP_TIMEOUT_SECONDS_ENV, - DEVBOX_GATEWAY_READY_TIMEOUT_SECONDS_ENV, DEVBOX_JWT_SIGNING_KEY_ENV, - DEVBOX_JWT_TTL_SECONDS_ENV, DEVBOX_NAMESPACE_ENV, DEVBOX_RUNTIME_IMAGE_ENV, DEVBOX_TOKEN_ENV, - DEVBOX_WAIT_TIMEOUT_SECONDS_ENV, HOST_ENV, JWT_SECRET_ENV, MAX_DEPLOYMENTS_ENV, - MAX_SESSIONS_ENV, PORT_ENV, SEALOS_HOST_ENV, SESSION_RUNTIME_ENV, - SESSION_SWEEP_INTERVAL_MS_ENV, SESSION_TTL_MS_ENV, read_bool_flag, read_env, read_u16, - read_u64, read_usize, + BRIDGE_CWD_ENV, CODEX_BIN_ENV, DEBUG_ENV, DEFAULT_MODEL_ENV, HOST_ENV, JWT_SECRET_ENV, + MAX_SESSIONS_ENV, PORT_ENV, SESSION_SWEEP_INTERVAL_MS_ENV, SESSION_TTL_MS_ENV, read_bool_flag, + read_env, read_u16, read_u64, read_usize, }; -use crate::error::AppError; #[derive(Debug, Clone)] pub struct ClientInfo { @@ -25,26 +19,6 @@ pub struct AuthConfig { pub jwt_secret: String, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SessionRuntimeMode { - Embedded, - Devbox, -} - -#[derive(Debug, Clone)] -pub struct DevboxConfig { - pub base_url: String, - pub namespace: String, - pub token: Option, - pub jwt_signing_key: Option, - pub jwt_ttl_seconds: u64, - pub runtime_image: Option, - pub archive_after_pause_time: String, - pub wait_timeout: Duration, - pub gateway_ready_timeout: Duration, - pub bootstrap_timeout: Duration, -} - #[derive(Debug, Clone)] pub struct AppConfig { pub host: String, @@ -55,21 +29,15 @@ pub struct AppConfig { pub debug: bool, pub default_model: Option, pub max_sessions: usize, - pub max_deployments: usize, pub session_ttl: Duration, - pub deployment_timeout: Duration, pub session_sweep_interval: Duration, pub client_info: ClientInfo, pub auth: Option, - pub session_runtime: SessionRuntimeMode, - pub devbox: Option, } impl AppConfig { - pub fn from_env(root_dir: PathBuf) -> Result { + pub fn from_env(root_dir: PathBuf) -> std::io::Result { let public_dir = root_dir.join("public"); - let session_runtime = read_session_runtime()?; - let devbox = read_devbox_config(session_runtime); Ok(Self { host: read_env(HOST_ENV).unwrap_or_else(|| "0.0.0.0".to_string()), @@ -82,13 +50,9 @@ impl AppConfig { debug: read_bool_flag(DEBUG_ENV), default_model: read_env(DEFAULT_MODEL_ENV), max_sessions: read_usize(MAX_SESSIONS_ENV).unwrap_or(12), - max_deployments: read_usize(MAX_DEPLOYMENTS_ENV).unwrap_or(4), session_ttl: Duration::from_millis( read_u64(SESSION_TTL_MS_ENV).unwrap_or(30 * 60 * 1000), ), - deployment_timeout: Duration::from_millis( - read_u64(DEPLOYMENT_TIMEOUT_MS_ENV).unwrap_or(60 * 60 * 1000), - ), session_sweep_interval: Duration::from_millis( read_u64(SESSION_SWEEP_INTERVAL_MS_ENV).unwrap_or(60 * 1000), ), @@ -98,97 +62,6 @@ impl AppConfig { version: env!("CARGO_PKG_VERSION").to_string(), }, auth: read_env(JWT_SECRET_ENV).map(|jwt_secret| AuthConfig { jwt_secret }), - session_runtime, - devbox, - }) - } -} - -fn read_session_runtime() -> Result { - parse_session_runtime(read_env(SESSION_RUNTIME_ENV).as_deref()).map_err(AppError::bad_request) -} - -fn parse_session_runtime(value: Option<&str>) -> Result { - match value - .unwrap_or("embedded") - .trim() - .to_ascii_lowercase() - .as_str() - { - "embedded" => Ok(SessionRuntimeMode::Embedded), - "devbox" => Ok(SessionRuntimeMode::Devbox), - other => Err(format!( - "Unsupported CODEX_GATEWAY_SESSION_RUNTIME value `{other}`. Supported values: embedded, devbox" - )), - } -} - -fn read_devbox_config(session_runtime: SessionRuntimeMode) -> Option { - if session_runtime != SessionRuntimeMode::Devbox { - return None; - } - - let base_url = read_env(DEVBOX_BASE_URL_ENV).or_else(|| { - read_env(SEALOS_HOST_ENV).map(|host| { - let normalized = host - .trim() - .trim_end_matches('/') - .trim_start_matches("https://") - .trim_start_matches("http://") - .to_string(); - format!("https://devbox-server.{normalized}") }) - }); - - Some(DevboxConfig { - base_url: base_url.unwrap_or_default(), - namespace: read_env(DEVBOX_NAMESPACE_ENV).unwrap_or_else(|| "ns-test".to_string()), - token: read_env(DEVBOX_TOKEN_ENV), - jwt_signing_key: read_env(DEVBOX_JWT_SIGNING_KEY_ENV), - jwt_ttl_seconds: read_u64(DEVBOX_JWT_TTL_SECONDS_ENV).unwrap_or(4 * 60 * 60), - runtime_image: read_env(DEVBOX_RUNTIME_IMAGE_ENV), - archive_after_pause_time: read_env(DEVBOX_ARCHIVE_AFTER_PAUSE_TIME_ENV) - .unwrap_or_else(|| "24h".to_string()), - wait_timeout: Duration::from_secs(read_u64(DEVBOX_WAIT_TIMEOUT_SECONDS_ENV).unwrap_or(60)), - gateway_ready_timeout: Duration::from_secs( - read_u64(DEVBOX_GATEWAY_READY_TIMEOUT_SECONDS_ENV).unwrap_or(60), - ), - bootstrap_timeout: Duration::from_secs( - read_u64(DEVBOX_BOOTSTRAP_TIMEOUT_SECONDS_ENV).unwrap_or(300), - ), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn session_runtime_defaults_to_embedded() { - assert_eq!( - parse_session_runtime(None).expect("runtime parses"), - SessionRuntimeMode::Embedded - ); - } - - #[test] - fn session_runtime_accepts_embedded_and_devbox() { - assert_eq!( - parse_session_runtime(Some("embedded")).expect("runtime parses"), - SessionRuntimeMode::Embedded - ); - assert_eq!( - parse_session_runtime(Some("devbox")).expect("runtime parses"), - SessionRuntimeMode::Devbox - ); - } - - #[test] - fn session_runtime_rejects_local() { - let error = parse_session_runtime(Some("local")).expect_err("local is not supported"); - - assert!(error.contains("embedded")); - assert!(error.contains("devbox")); - assert!(error.contains("local")); } } diff --git a/src/devbox.rs b/src/devbox.rs index 08a528a..c81646c 100644 --- a/src/devbox.rs +++ b/src/devbox.rs @@ -543,10 +543,6 @@ fn create_runtime_env(ttl: Duration) -> serde_json::Value { let mut env = serde_json::Map::new(); env.insert("CODEX_GATEWAY_HOST".to_string(), json!("0.0.0.0")); env.insert("CODEX_GATEWAY_PORT".to_string(), json!("1317")); - env.insert( - "CODEX_GATEWAY_SESSION_RUNTIME".to_string(), - json!("embedded"), - ); env.insert( "CODEX_GATEWAY_SESSION_TTL_MS".to_string(), json!(ttl.as_millis().to_string()), diff --git a/src/env_config.rs b/src/env_config.rs index 9c3683d..abe5b21 100644 --- a/src/env_config.rs +++ b/src/env_config.rs @@ -8,38 +8,12 @@ pub const CODEX_BIN_ENV: &[&str] = &["CODEX_GATEWAY_CODEX_BIN"]; pub const DEBUG_ENV: &[&str] = &["CODEX_GATEWAY_DEBUG"]; pub const DEFAULT_MODEL_ENV: &[&str] = &["CODEX_GATEWAY_MODEL"]; pub const MAX_SESSIONS_ENV: &[&str] = &["CODEX_GATEWAY_MAX_SESSIONS"]; -pub const MAX_DEPLOYMENTS_ENV: &[&str] = &["CODEX_GATEWAY_MAX_DEPLOYMENTS"]; pub const SESSION_TTL_MS_ENV: &[&str] = &["CODEX_GATEWAY_SESSION_TTL_MS"]; -pub const DEPLOYMENT_TIMEOUT_MS_ENV: &[&str] = &["CODEX_GATEWAY_DEPLOYMENT_TIMEOUT_MS"]; pub const SESSION_SWEEP_INTERVAL_MS_ENV: &[&str] = &["CODEX_GATEWAY_SESSION_SWEEP_INTERVAL_MS"]; pub const OPENAI_API_KEY_ENV: &[&str] = &["CODEX_GATEWAY_OPENAI_API_KEY"]; pub const OPENAI_BASE_URL_ENV: &[&str] = &["CODEX_GATEWAY_OPENAI_BASE_URL"]; pub const CODEX_HOME_ENV: &[&str] = &["CODEX_GATEWAY_CODEX_HOME"]; pub const JWT_SECRET_ENV: &[&str] = &["CODEX_GATEWAY_JWT_SECRET"]; -pub const SESSION_RUNTIME_ENV: &[&str] = &["CODEX_GATEWAY_SESSION_RUNTIME"]; -pub const DEVBOX_BASE_URL_ENV: &[&str] = &["CODEX_GATEWAY_DEVBOX_BASE_URL"]; -pub const SEALOS_HOST_ENV: &[&str] = &["SEALOS_HOST"]; -pub const DEVBOX_TOKEN_ENV: &[&str] = &["CODEX_GATEWAY_DEVBOX_TOKEN", "DEVBOX_TOKEN"]; -pub const DEVBOX_JWT_SIGNING_KEY_ENV: &[&str] = &[ - "CODEX_GATEWAY_DEVBOX_JWT_SIGNING_KEY", - "DEVBOX_JWT_SIGNING_KEY", -]; -pub const DEVBOX_JWT_TTL_SECONDS_ENV: &[&str] = &[ - "CODEX_GATEWAY_DEVBOX_JWT_TTL_SECONDS", - "DEVBOX_JWT_TTL_SECONDS", -]; -pub const DEVBOX_NAMESPACE_ENV: &[&str] = &["CODEX_GATEWAY_DEVBOX_NAMESPACE", "DEVBOX_NAMESPACE"]; -pub const DEVBOX_RUNTIME_IMAGE_ENV: &[&str] = - &["CODEX_GATEWAY_DEVBOX_RUNTIME_IMAGE", "DEVBOX_RUNTIME_IMAGE"]; -pub const DEVBOX_ARCHIVE_AFTER_PAUSE_TIME_ENV: &[&str] = &[ - "CODEX_GATEWAY_DEVBOX_ARCHIVE_AFTER_PAUSE_TIME", - "DEVBOX_ARCHIVE_AFTER_PAUSE_TIME", -]; -pub const DEVBOX_BOOTSTRAP_TIMEOUT_SECONDS_ENV: &[&str] = - &["CODEX_GATEWAY_DEVBOX_BOOTSTRAP_TIMEOUT_SECONDS"]; -pub const DEVBOX_WAIT_TIMEOUT_SECONDS_ENV: &[&str] = &["CODEX_GATEWAY_DEVBOX_WAIT_TIMEOUT_SECONDS"]; -pub const DEVBOX_GATEWAY_READY_TIMEOUT_SECONDS_ENV: &[&str] = - &["CODEX_GATEWAY_DEVBOX_GATEWAY_READY_TIMEOUT_SECONDS"]; pub fn read_env(names: &[&str]) -> Option { for name in names { diff --git a/src/error.rs b/src/error.rs index 10d261d..26fd71c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -16,10 +16,6 @@ pub enum AppError { Json(#[from] serde_json::Error), #[error(transparent)] Runtime(#[from] crate::runtime::RuntimeError), - #[error(transparent)] - Devbox(#[from] crate::devbox::DevboxError), - #[error(transparent)] - RemoteGateway(#[from] crate::remote_gateway::RemoteGatewayError), #[error("Background task channel closed")] ChannelClosed, } diff --git a/src/lib.rs b/src/lib.rs index 783d9f4..3002bea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,11 +1,9 @@ pub mod auth; +pub mod brain; pub mod bridge; pub mod config; -pub mod deployments; -pub mod devbox; pub mod env_config; pub mod error; pub mod models; -pub mod remote_gateway; pub mod runtime; pub mod session_manager; diff --git a/src/main.rs b/src/main.rs index e49bfd0..bf7004e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,8 +22,9 @@ use tracing::{error, info, warn}; use uuid::Uuid; use codex_gateway::auth::{AuthState, auth_middleware}; -use codex_gateway::deployments::{ - DeploymentRegistry, build_deployment_prompt, deployment_status_from_thread, +use codex_gateway::brain::deployments::{ + BrainDeploymentRegistry, BrainDeploymentStatusResponse, brain_deployment_status_from_thread, + build_brain_deployment_prompt, }; use codex_gateway::error::AppError; use codex_gateway::models::BridgeEvent; @@ -33,7 +34,7 @@ use codex_gateway::{config::AppConfig, session_manager::SessionManager}; #[derive(Clone)] struct AppState { session_manager: SessionManager, - deployment_registry: DeploymentRegistry, + brain_deployment_registry: BrainDeploymentRegistry, public_dir: PathBuf, } @@ -73,7 +74,7 @@ struct ThreadListQuery { #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] -struct CreateDeploymentRequest { +struct CreateBrainDeploymentRequest { github_token: Option, repository: Option, branch: Option, @@ -94,20 +95,18 @@ async fn main() -> Result<(), AppError> { auth_enabled = config.auth.is_some(), debug = config.debug, max_sessions = config.max_sessions, - max_deployments = config.max_deployments, + brain_deployment_runtime = "embedded", session_ttl_ms = config.session_ttl.as_millis() as u64, - deployment_timeout_ms = config.deployment_timeout.as_millis() as u64, session_sweep_interval_ms = config.session_sweep_interval.as_millis() as u64, "gateway configuration loaded" ); maybe_login_with_api_key(&config.codex_bin)?; let session_manager = SessionManager::new(config.clone()); - let deployment_registry = - DeploymentRegistry::new(config.max_deployments, config.deployment_timeout); + let brain_deployment_registry = BrainDeploymentRegistry::new(); let state = AppState { session_manager: session_manager.clone(), - deployment_registry, + brain_deployment_registry, public_dir: config.public_dir.clone(), }; @@ -155,8 +154,11 @@ fn build_router(state: AppState) -> Router { get(legacy_single_session_gone).post(legacy_single_session_gone), ) .route("/api/sessions", post(create_session)) - .route("/api/deployments", post(create_deployment)) - .route("/api/deployments/{thread_id}", get(get_deployment)) + .route("/api/brain/deployments", post(create_brain_deployment)) + .route( + "/api/brain/deployments/{thread_id}", + get(get_brain_deployment), + ) .route("/api/threads", get(get_threads)) .route("/api/threads/{thread_id}", get(get_thread)) .route("/api/sessions/{id}/state", get(get_session_state)) @@ -262,11 +264,11 @@ async fn create_session( }))) } -async fn create_deployment( +async fn create_brain_deployment( State(state): State, body: Bytes, ) -> Result { - let request: CreateDeploymentRequest = parse_json_body(body)?; + let request: CreateBrainDeploymentRequest = parse_json_body(body)?; let github_token = trim_optional(request.github_token) .ok_or_else(|| AppError::bad_request("githubToken must not be empty"))?; let repository = trim_optional(request.repository) @@ -275,13 +277,16 @@ async fn create_deployment( let branch = trim_optional(request.branch); validate_branch(branch.as_deref())?; - let create_guard = state.deployment_registry.try_begin_create()?; + let create_guard = state.brain_deployment_registry.try_begin_create()?; info!("creating deployment task"); - let (session_id, _session, snapshot) = state.session_manager.create_session(None, None).await?; + let (session_id, _session, snapshot) = state + .session_manager + .create_brain_deployment_session() + .await?; state .session_manager - .touch_session_for(&session_id, state.deployment_registry.timeout())?; + .touch_session_for(&session_id, state.brain_deployment_registry.timeout())?; let Some(thread_id) = snapshot.thread_id.clone() else { if let Err(error) = state .session_manager @@ -299,15 +304,8 @@ async fn create_deployment( )); }; - let skill_preinstalled = state - .session_manager - .is_devbox_runtime_session(&session_id)?; - let prompt = build_deployment_prompt( - &repository, - branch.as_deref(), - &github_token, - skill_preinstalled, - ); + let prompt = + build_brain_deployment_prompt(&repository, branch.as_deref(), &github_token, false); if let Err(error) = state .session_manager .send_prompt(&session_id, &prompt) @@ -349,16 +347,16 @@ async fn create_deployment( )) } -async fn get_deployment( +async fn get_brain_deployment( State(state): State, Path(thread_id): Path, -) -> Result, AppError> { +) -> Result, AppError> { let thread_id = thread_id.trim().to_string(); if thread_id.is_empty() { return Err(AppError::bad_request("threadId must not be empty")); } - let Some(record) = state.deployment_registry.get(&thread_id) else { + let Some(record) = state.brain_deployment_registry.get(&thread_id) else { return Err(AppError::not_found(format!( "Unknown deployment thread: {thread_id}" ))); @@ -368,7 +366,7 @@ async fn get_deployment( } if record.is_timed_out() { let response = record.timeout_response(); - mark_deployment_terminal_and_close( + mark_brain_deployment_terminal_and_close( &state, &thread_id, &record.session_id, @@ -379,11 +377,14 @@ async fn get_deployment( } if state .session_manager - .touch_session_for(&record.session_id, state.deployment_registry.timeout()) + .touch_session_for( + &record.session_id, + state.brain_deployment_registry.timeout(), + ) .is_err() { let response = record.stopped_response(); - mark_deployment_terminal_and_close( + mark_brain_deployment_terminal_and_close( &state, &thread_id, &record.session_id, @@ -407,7 +408,7 @@ async fn get_deployment( } Err(error) if looks_like_app_server_stopped(&error) => { let response = record.stopped_response(); - mark_deployment_terminal_and_close( + mark_brain_deployment_terminal_and_close( &state, &thread_id, &record.session_id, @@ -430,9 +431,9 @@ async fn get_deployment( ))); } - let response = deployment_status_from_thread(&thread_id, &thread_result); + let response = brain_deployment_status_from_thread(&thread_id, &thread_result); if response.status != "running" { - mark_deployment_terminal_and_close( + mark_brain_deployment_terminal_and_close( &state, &thread_id, &record.session_id, @@ -450,14 +451,14 @@ async fn get_deployment( Ok(Json(response)) } -async fn mark_deployment_terminal_and_close( +async fn mark_brain_deployment_terminal_and_close( state: &AppState, thread_id: &str, session_id: &str, - response: codex_gateway::deployments::DeploymentStatusResponse, + response: BrainDeploymentStatusResponse, ) { state - .deployment_registry + .brain_deployment_registry .mark_terminal(thread_id, response.clone()); match state @@ -957,11 +958,14 @@ fn extract_session_id(path: &str) -> Option { fn extract_thread_id(path: &str) -> Option { let segments = path.trim_matches('/').split('/').collect::>(); - if segments.len() >= 3 + if segments.len() >= 3 && segments[0] == "api" && segments[1] == "threads" { + Some(segments[2].to_string()) + } else if segments.len() >= 4 && segments[0] == "api" - && (segments[1] == "threads" || segments[1] == "deployments") + && segments[1] == "brain" + && segments[2] == "deployments" { - Some(segments[2].to_string()) + Some(segments[3].to_string()) } else { None } diff --git a/src/session_manager.rs b/src/session_manager.rs index 0648a3a..814313f 100644 --- a/src/session_manager.rs +++ b/src/session_manager.rs @@ -3,8 +3,6 @@ use std::sync::{Arc, RwLock}; use std::time::{Duration, Instant}; use chrono::{DateTime, Utc}; -use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; -use serde::Serialize; use serde_json::Value; use tokio::sync::Mutex; use tokio::sync::broadcast::Receiver; @@ -12,11 +10,9 @@ use tracing::{error, info, warn}; use uuid::Uuid; use crate::bridge::{BridgeOptions, CodexAppServerBridge}; -use crate::config::{AppConfig, AuthConfig, SessionRuntimeMode}; -use crate::devbox::{DevboxRuntime, DevboxRuntimeManager}; +use crate::config::AppConfig; use crate::error::AppError; use crate::models::{BridgeEvent, BridgeStateSnapshot, SessionInfo}; -use crate::remote_gateway::{RemoteGatewayClient, RemoteSession}; #[derive(Clone)] pub struct SessionManager { @@ -25,7 +21,6 @@ pub struct SessionManager { struct SessionManagerInner { config: AppConfig, - devbox_runtime_manager: Option, started_at: Instant, sessions: RwLock>>, create_lock: Mutex<()>, @@ -39,14 +34,6 @@ struct Session { enum SessionBackend { Embedded { bridge: CodexAppServerBridge }, - RemoteDevbox(Box), -} - -struct RemoteDevboxBackend { - runtime: DevboxRuntime, - gateway: RemoteGatewayClient, - remote_session_id: String, - state: RwLock, } struct SessionMetadata { @@ -59,7 +46,6 @@ impl SessionManager { pub fn new(config: AppConfig) -> Self { let manager = Self { inner: Arc::new(SessionManagerInner { - devbox_runtime_manager: config.devbox.clone().map(DevboxRuntimeManager::new), config, started_at: Instant::now(), sessions: RwLock::new(HashMap::new()), @@ -112,7 +98,7 @@ impl SessionManager { ); let metadata = Arc::new(SessionMetadata::new(self.inner.config.session_ttl)); let backend = self - .create_session_backend(&id, model, resume_thread_id, &metadata) + .create_embedded_backend(model, resume_thread_id, &metadata) .await?; let session = Arc::new(Session { @@ -187,6 +173,47 @@ impl SessionManager { session.read_thread(thread_id).await } + pub async fn create_brain_deployment_session( + &self, + ) -> Result<(String, SessionInfo, BridgeStateSnapshot), AppError> { + let _guard = self.inner.create_lock.lock().await; + self.sweep_expired_sessions().await; + + if self.count() >= self.inner.config.max_sessions { + warn!( + active_sessions = self.count(), + max_sessions = self.inner.config.max_sessions, + "maximum concurrent sessions reached" + ); + return Err(AppError::service_unavailable(format!( + "Maximum concurrent sessions reached ({})", + self.inner.config.max_sessions + ))); + } + + let id = Uuid::new_v4().to_string(); + info!(session_id = %id, "allocating Brain deployment session"); + let metadata = Arc::new(SessionMetadata::new(self.inner.config.session_ttl)); + let backend = self.create_embedded_backend(None, None, &metadata).await?; + + let session = Arc::new(Session { + id: id.clone(), + backend, + metadata, + }); + let info = session.info(); + let state = session.state(); + + self.inner + .sessions + .write() + .unwrap() + .insert(id.clone(), session); + info!("Brain deployment session created {}", id); + + Ok((id, info, state)) + } + pub fn get_state(&self, session_id: &str) -> Result { let session = self.require_session(session_id)?; Ok(session.state()) @@ -200,17 +227,9 @@ impl SessionManager { pub fn touch_session_for(&self, session_id: &str, ttl: Duration) -> Result<(), AppError> { let session = self.require_session(session_id)?; session.metadata.touch(ttl); - if session.is_devbox_runtime() { - session.refresh_devbox_lease(ttl)?; - } Ok(()) } - pub fn is_devbox_runtime_session(&self, session_id: &str) -> Result { - let session = self.require_session(session_id)?; - Ok(session.is_devbox_runtime()) - } - pub fn subscribe( &self, session_id: &str, @@ -219,11 +238,6 @@ impl SessionManager { info!(session_id = %session_id, "subscribing to session events"); let receiver = match &session.backend { SessionBackend::Embedded { bridge } => bridge.subscribe(), - SessionBackend::RemoteDevbox(_) => { - return Err(AppError::internal( - "Remote Devbox session events are not proxied yet", - )); - } }; Ok((session.info(), session.state(), receiver)) } @@ -304,23 +318,6 @@ impl SessionManager { Ok(()) } - async fn create_session_backend( - &self, - session_id: &str, - model: Option, - resume_thread_id: Option, - metadata: &Arc, - ) -> Result { - if self.inner.config.session_runtime == SessionRuntimeMode::Devbox { - return self - .create_remote_devbox_backend(session_id, model, resume_thread_id) - .await; - } - - self.create_embedded_backend(model, resume_thread_id, metadata) - .await - } - async fn create_embedded_backend( &self, model: Option, @@ -350,59 +347,6 @@ impl SessionManager { Ok(SessionBackend::Embedded { bridge }) } - async fn create_remote_devbox_backend( - &self, - session_id: &str, - model: Option, - resume_thread_id: Option, - ) -> Result { - let manager = self - .inner - .devbox_runtime_manager - .as_ref() - .ok_or_else(|| AppError::internal("Devbox runtime manager is not configured"))?; - - info!(session_id = %session_id, "creating devbox runtime for session"); - let runtime = manager - .create_for_session(session_id, self.inner.config.session_ttl) - .await?; - info!("devbox runtime ready for session"); - - let gateway_url = runtime - .gateway_url - .clone() - .ok_or_else(|| AppError::internal("Devbox gateway URL is not available"))?; - let gateway_auth_token = runtime - .gateway_auth_token - .clone() - .or_else(|| create_gateway_auth_token(self.inner.config.auth.as_ref())); - let gateway = RemoteGatewayClient::with_auth_token(gateway_url, gateway_auth_token); - if let Err(error) = gateway - .wait_for_ready(manager.config().gateway_ready_timeout) - .await - { - runtime.cleanup_after_create_failure().await; - return Err(error.into()); - } - let RemoteSession { id, info: _, state } = - match gateway.create_session(model, resume_thread_id).await { - Ok(session) => session, - Err(error) => { - runtime.cleanup_after_create_failure().await; - return Err(error.into()); - } - }; - - let backend = RemoteDevboxBackend { - runtime, - gateway, - remote_session_id: id, - state: RwLock::new(state), - }; - - Ok(SessionBackend::RemoteDevbox(Box::new(backend))) - } - async fn sweep_expired_sessions(&self) { let now = Utc::now(); let expired_ids = self @@ -471,31 +415,6 @@ impl SessionManager { } } -#[derive(Debug, Serialize)] -struct GatewayAuthClaims { - exp: usize, - iat: usize, -} - -fn create_gateway_auth_token(auth: Option<&AuthConfig>) -> Option { - let auth = auth?; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_secs() as usize) - .unwrap_or_default(); - let claims = GatewayAuthClaims { - iat: now, - exp: now + 24 * 60 * 60, - }; - - encode( - &Header::new(Algorithm::HS256), - &claims, - &EncodingKey::from_secret(auth.jwt_secret.as_bytes()), - ) - .ok() -} - impl Session { fn info(&self) -> SessionInfo { SessionInfo { @@ -509,44 +428,18 @@ impl Session { fn state(&self) -> BridgeStateSnapshot { match &self.backend { SessionBackend::Embedded { bridge } => bridge.get_state(), - SessionBackend::RemoteDevbox(backend) => backend.state.read().unwrap().clone(), - } - } - - fn is_devbox_runtime(&self) -> bool { - matches!(self.backend, SessionBackend::RemoteDevbox(_)) - } - - fn refresh_devbox_lease(&self, ttl: Duration) -> Result<(), AppError> { - match &self.backend { - SessionBackend::Embedded { .. } => Ok(()), - SessionBackend::RemoteDevbox(backend) => { - let runtime = backend.runtime.clone(); - tokio::spawn(async move { - if runtime.refresh_pause_for(ttl).await.is_err() { - warn!("devbox runtime lease refresh failed"); - } - }); - Ok(()) - } } } async fn list_threads(&self, params: Value) -> Result { match &self.backend { SessionBackend::Embedded { bridge } => bridge.list_threads(params).await, - SessionBackend::RemoteDevbox(backend) => { - Ok(backend.gateway.list_threads(params).await?) - } } } async fn read_thread(&self, thread_id: &str) -> Result { match &self.backend { SessionBackend::Embedded { bridge } => bridge.read_thread(thread_id).await, - SessionBackend::RemoteDevbox(backend) => { - Ok(backend.gateway.read_thread(thread_id).await?) - } } } @@ -556,14 +449,6 @@ impl Session { bridge.send_prompt(prompt).await?; Ok(bridge.get_state()) } - SessionBackend::RemoteDevbox(backend) => { - let snapshot = backend - .gateway - .send_prompt(&backend.remote_session_id, prompt) - .await?; - *backend.state.write().unwrap() = snapshot.clone(); - Ok(snapshot) - } } } @@ -573,14 +458,6 @@ impl Session { bridge.interrupt_turn().await?; Ok(bridge.get_state()) } - SessionBackend::RemoteDevbox(backend) => { - let snapshot = backend - .gateway - .interrupt_turn(&backend.remote_session_id) - .await?; - *backend.state.write().unwrap() = snapshot.clone(); - Ok(snapshot) - } } } @@ -593,14 +470,6 @@ impl Session { bridge.start_new_thread(model).await?; Ok(bridge.get_state()) } - SessionBackend::RemoteDevbox(backend) => { - let snapshot = backend - .gateway - .start_new_thread(&backend.remote_session_id, model) - .await?; - *backend.state.write().unwrap() = snapshot.clone(); - Ok(snapshot) - } } } @@ -610,14 +479,6 @@ impl Session { bridge.resume_thread(thread_id).await?; Ok(bridge.get_state()) } - SessionBackend::RemoteDevbox(backend) => { - let snapshot = backend - .gateway - .resume_thread(&backend.remote_session_id, thread_id) - .await?; - *backend.state.write().unwrap() = snapshot.clone(); - Ok(snapshot) - } } } @@ -627,20 +488,6 @@ impl Session { bridge.broadcast_session_closed(&self.id, reason); bridge.stop().await } - SessionBackend::RemoteDevbox(backend) => { - if backend - .gateway - .delete_session(&backend.remote_session_id) - .await - .is_err() - { - warn!("remote devbox session cleanup failed"); - } - if backend.runtime.delete().await.is_err() { - warn!("devbox runtime cleanup failed"); - } - Ok(()) - } } } } diff --git a/tests/http_integration.rs b/tests/http_integration.rs index edfd694..91c95c2 100644 --- a/tests/http_integration.rs +++ b/tests/http_integration.rs @@ -85,7 +85,7 @@ fn gateway_session_thread_and_turn_http_flow_against_fake_app_server() { let (status, bad_deployment) = gateway.json_request( "POST", - "/api/deployments", + "/api/brain/deployments", Some(r#"{"githubToken":"ghp_fake","repository":"missing-owner"}"#), ); assert_eq!(status, 400); @@ -98,7 +98,7 @@ fn gateway_session_thread_and_turn_http_flow_against_fake_app_server() { let (status, bad_branch) = gateway.json_request( "POST", - "/api/deployments", + "/api/brain/deployments", Some(r#"{"githubToken":"ghp_fake","repository":"owner/repo","branch":"main`bad"}"#), ); assert_eq!(status, 400); @@ -109,8 +109,19 @@ fn gateway_session_thread_and_turn_http_flow_against_fake_app_server() { .is_some_and(|message| message.contains("branch")) ); + let (status, old_deployment_route) = gateway.json_request( + "POST", + "/api/deployments", + Some(r#"{"githubToken":"ghp_fake","repository":"owner/repo"}"#), + ); + assert_eq!(status, 404); + assert_eq!( + old_deployment_route.get("error").and_then(Value::as_str), + Some("Not found") + ); + let (status, missing_deployment) = - gateway.json_request("GET", "/api/deployments/thread-resume", None); + gateway.json_request("GET", "/api/brain/deployments/thread-resume", None); assert_eq!(status, 404); assert!( missing_deployment @@ -121,7 +132,7 @@ fn gateway_session_thread_and_turn_http_flow_against_fake_app_server() { let (status, deployment) = gateway.json_request( "POST", - "/api/deployments", + "/api/brain/deployments", Some(r#"{"githubToken":"ghp_fake","repository":"owner/repo","branch":"main"}"#), ); assert_eq!(status, 202); @@ -129,19 +140,16 @@ fn gateway_session_thread_and_turn_http_flow_against_fake_app_server() { deployment.get("status").and_then(Value::as_str), Some("running") ); - let thread_id = deployment + let deployment_thread_id = deployment .get("threadId") .and_then(Value::as_str) .expect("deployment thread id") .to_string(); + assert_eq!(deployment_thread_id, "thread-1"); - let deployment_status = gateway - .wait_for_json(&format!("/api/deployments/{thread_id}"), |payload| { - payload.get("status").and_then(Value::as_str) == Some("succeeded") - }); - assert_eq!( - deployment_status.get("threadId").and_then(Value::as_str), - Some(thread_id.as_str()) + let deployment_status = gateway.wait_for_json( + &format!("/api/brain/deployments/{deployment_thread_id}"), + |payload| payload.get("status").and_then(Value::as_str) == Some("succeeded"), ); assert_eq!( deployment_status.get("image").and_then(Value::as_str), @@ -210,6 +218,7 @@ impl GatewayProcess { .env("CODEX_GATEWAY_CWD", std::env::current_dir().expect("cwd")) .env("CODEX_GATEWAY_CODEX_HOME", &codex_home) .env("CODEX_GATEWAY_MAX_SESSIONS", "4") + .env("CODEX_GATEWAY_DEPLOYMENT_TIMEOUT_MS", "1") .env("CODEX_GATEWAY_SESSION_TTL_MS", "60000") .env("CODEX_GATEWAY_SESSION_SWEEP_INTERVAL_MS", "60000") .env_remove("CODEX_GATEWAY_MODEL") From 06136b48a049cd06ba73efc4968e33ba972b7477 Mon Sep 17 00:00:00 2001 From: Che <30403707+Che-Zhu@users.noreply.github.com> Date: Wed, 20 May 2026 14:47:17 +0800 Subject: [PATCH 2/4] feat: include sealos template in brain deployment result --- README.md | 2 +- README_zh.md | 2 +- src/brain/deployments.rs | 132 +++++++++++++++++++++++++++++++----- tests/http_integration.rs | 4 ++ tests/support/fake_codex.rs | 2 +- 5 files changed, 122 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 50b51b8..83493da 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ If deployment work runs in Devbox, an external system is responsible for creatin `POST /api/brain/deployments` is a Brain application API. It is not intended to describe a general deployment product surface. -The endpoint creates a local embedded Codex task that installs the deployment skill if needed, builds the repository image, pushes it to GHCR, and reports a machine-readable deployment result. It does not expose intermediate Codex output or accept follow-up user turns. +The endpoint creates a local embedded Codex task that installs the deployment skill if needed, builds the repository image, pushes it to GHCR, generates a Sealos template, and reports a machine-readable deployment result containing both the image reference and template content. It does not expose intermediate Codex output or accept follow-up user turns. ## HTTP API diff --git a/README_zh.md b/README_zh.md index ff49449..d293e23 100644 --- a/README_zh.md +++ b/README_zh.md @@ -23,7 +23,7 @@ Brain deployment task 也使用同一个 embedded runtime,但对外暴露的 `POST /api/brain/deployments` 是 Brain 应用接口,不是通用部署产品接口。 -这个接口会创建一个本地 embedded Codex task。该 task 会按需安装 deployment skill,构建仓库镜像,推送到 GHCR,并返回机器可读的部署结果。接口不暴露 Codex 中间输出,也不接受用户继续输入。 +这个接口会创建一个本地 embedded Codex task。该 task 会按需安装 deployment skill,构建仓库镜像,推送到 GHCR,生成 Sealos template,并返回同时包含镜像地址和 template 内容的机器可读部署结果。接口不暴露 Codex 中间输出,也不接受用户继续输入。 ## HTTP API diff --git a/src/brain/deployments.rs b/src/brain/deployments.rs index 4eec340..582e608 100644 --- a/src/brain/deployments.rs +++ b/src/brain/deployments.rs @@ -48,6 +48,7 @@ pub struct BrainDeploymentStatusResponse { pub status: String, pub message: String, pub image: Option, + pub template: Option, pub error: Option, } @@ -55,6 +56,7 @@ pub struct BrainDeploymentStatusResponse { struct BrainDeploymentResultLine { status: String, image: Option, + template: Option, message: Option, error: Option, } @@ -156,6 +158,7 @@ impl BrainDeploymentRecord { status: "failed".to_string(), message: "Deployment failed".to_string(), image: None, + template: None, error: Some("Deployment timed out before producing a result".to_string()), } } @@ -166,6 +169,7 @@ impl BrainDeploymentRecord { status: "failed".to_string(), message: "Deployment failed".to_string(), image: None, + template: None, error: Some("Deployment session is no longer running".to_string()), } } @@ -219,7 +223,7 @@ pub fn build_brain_deployment_prompt( .unwrap_or_else(|| "Use the repository default branch.".to_string()); let skill_instruction = if skill_preinstalled { format!( - "The deployment skill has already been installed by the gateway runtime bootstrap. Use the {BRAIN_DEPLOYMENT_SKILL_TRIGGER} deployment workflow if it is available. If it is unavailable, perform the equivalent workflow: inspect the repository, generate or reuse a Dockerfile, verify the image build, publish the image to GHCR, and report the pushed image reference." + "The deployment skill has already been installed by the gateway runtime bootstrap. Use the {BRAIN_DEPLOYMENT_SKILL_TRIGGER} deployment workflow if it is available. If it is unavailable, perform the equivalent workflow: inspect the repository, generate or reuse a Dockerfile, verify the image build, publish the image to GHCR, generate a Sealos template, and report the pushed image reference plus the template content." ) } else { format!( @@ -228,7 +232,7 @@ pub fn build_brain_deployment_prompt( npx --yes skills add https://github.com/zjy365/seakills/tree/sandbox-skill-lite -y - If the install command fails, stop and return a failed `DEPLOYMENT_RESULT` with the install failure reason. -After the skill is installed, use the {BRAIN_DEPLOYMENT_SKILL_TRIGGER} deployment workflow if it is available. If it is unavailable, perform the equivalent workflow: inspect the repository, generate or reuse a Dockerfile, verify the image build, publish the image to GHCR, and report the pushed image reference."# +After the skill is installed, use the {BRAIN_DEPLOYMENT_SKILL_TRIGGER} deployment workflow if it is available. If it is unavailable, perform the equivalent workflow: inspect the repository, generate or reuse a Dockerfile, verify the image build, publish the image to GHCR, generate a Sealos template, and report the pushed image reference plus the template content."# ) }; @@ -247,15 +251,17 @@ Constraints: - Do not ask follow-up questions. - Let the deployment workflow decide the GHCR image tag unless it already has a safer project-specific convention. - Do not guess or fabricate the image reference. Only report an image that was actually pushed successfully. +- After the deployment workflow generates `.sealos/template/index.yaml`, read that file and include its full YAML content in the final result `template` field. +- Do not report success unless `.sealos/template/index.yaml` exists and has non-empty content. Final machine-readable result: - The final assistant message must contain exactly one result line. - That line must start with `DEPLOYMENT_RESULT:` followed by compact JSON. -- The JSON object must contain `status`, `image`, `message`, and `error`. -- On success, use this exact one-line shape, replacing only the image and message values: -DEPLOYMENT_RESULT: {{"status":"succeeded","image":"ghcr.io/owner/repo:tag","message":"Deployment image pushed to GHCR","error":null}} +- The JSON object must contain `status`, `image`, `template`, `message`, and `error`. +- On success, use this exact one-line shape, replacing only the image, template, and message values: +DEPLOYMENT_RESULT: {{"status":"succeeded","image":"ghcr.io/owner/repo:tag","template":"apiVersion: app.sealos.io/v1\nkind: Template\n...","message":"Deployment image pushed to GHCR and Sealos template generated","error":null}} - On failure, use this exact one-line shape, replacing only the message and error values: -DEPLOYMENT_RESULT: {{"status":"failed","image":null,"message":"Deployment failed","error":"Concise failure reason"}} +DEPLOYMENT_RESULT: {{"status":"failed","image":null,"template":null,"message":"Deployment failed","error":"Concise failure reason"}} - Do not wrap the result line in Markdown or add any other text after it."# ) } @@ -275,6 +281,7 @@ pub fn brain_deployment_status_from_thread( status: "failed".to_string(), message: "Deployment failed".to_string(), image: None, + template: None, error: Some(error), }, BrainDeploymentResultState::Missing if thread_is_active(thread) => { @@ -283,6 +290,7 @@ pub fn brain_deployment_status_from_thread( status: "running".to_string(), message: "Deployment is still running".to_string(), image: None, + template: None, error: None, } } @@ -291,6 +299,7 @@ pub fn brain_deployment_status_from_thread( status: "failed".to_string(), message: "Deployment failed".to_string(), image: None, + template: None, error: Some("Deployment result was not found in thread history".to_string()), }, } @@ -309,16 +318,32 @@ fn brain_deployment_response_from_result( status: "failed".to_string(), message: "Deployment failed".to_string(), image: None, + template: None, error: Some("Deployment result did not include a valid GHCR image".to_string()), }; } + let template = non_empty_optional(result.template); + if template.is_none() { + return BrainDeploymentStatusResponse { + thread_id: thread_id.to_string(), + status: "failed".to_string(), + message: "Deployment failed".to_string(), + image: None, + template: None, + error: Some( + "Deployment result did not include a non-empty Sealos template".to_string(), + ), + }; + } BrainDeploymentStatusResponse { thread_id: thread_id.to_string(), status: "succeeded".to_string(), - message: trim_optional(result.message) - .unwrap_or_else(|| "Deployment image pushed to GHCR".to_string()), + message: trim_optional(result.message).unwrap_or_else(|| { + "Deployment image pushed to GHCR and Sealos template generated".to_string() + }), image, + template, error: None, } } @@ -329,9 +354,20 @@ fn brain_deployment_response_from_result( status: "failed".to_string(), message: "Deployment failed".to_string(), image: None, + template: None, error: Some("Failed deployment result must not include an image".to_string()), }; } + if result.template.is_some() { + return BrainDeploymentStatusResponse { + thread_id: thread_id.to_string(), + status: "failed".to_string(), + message: "Deployment failed".to_string(), + image: None, + template: None, + error: Some("Failed deployment result must not include a template".to_string()), + }; + } BrainDeploymentStatusResponse { thread_id: thread_id.to_string(), @@ -339,6 +375,7 @@ fn brain_deployment_response_from_result( message: trim_optional(result.message) .unwrap_or_else(|| "Deployment failed".to_string()), image: None, + template: None, error: trim_optional(result.error) .or_else(|| Some("Deployment failed without an error message".to_string())), } @@ -348,6 +385,7 @@ fn brain_deployment_response_from_result( status: "failed".to_string(), message: "Deployment failed".to_string(), image: None, + template: None, error: Some(format!("Unsupported deployment result status: {other}")), }, } @@ -425,7 +463,7 @@ fn parse_brain_deployment_result_from_text( } let value = serde_json::from_str::(json_text) .map_err(|error| format!("Failed to parse deployment result JSON: {error}"))?; - for key in ["status", "image", "message", "error"] { + for key in ["status", "image", "template", "message", "error"] { if value.get(key).is_none() { return Err(format!("Deployment result JSON is missing `{key}`")); } @@ -503,6 +541,10 @@ fn trim_optional(value: Option) -> Option { .filter(|value| !value.is_empty()) } +fn non_empty_optional(value: Option) -> Option { + value.filter(|value| !value.trim().is_empty()) +} + #[cfg(test)] mod tests { use serde_json::json; @@ -522,11 +564,12 @@ mod tests { assert!(prompt.contains("Mandatory first step")); assert!(prompt.contains("Do not guess or fabricate the image reference")); assert!(prompt.contains( - r#"DEPLOYMENT_RESULT: {"status":"succeeded","image":"ghcr.io/owner/repo:tag","message":"Deployment image pushed to GHCR","error":null}"# + r#"DEPLOYMENT_RESULT: {"status":"succeeded","image":"ghcr.io/owner/repo:tag","template":"apiVersion: app.sealos.io/v1\nkind: Template\n...","message":"Deployment image pushed to GHCR and Sealos template generated","error":null}"# )); assert!(prompt.contains( - r#"DEPLOYMENT_RESULT: {"status":"failed","image":null,"message":"Deployment failed","error":"Concise failure reason"}"# + r#"DEPLOYMENT_RESULT: {"status":"failed","image":null,"template":null,"message":"Deployment failed","error":"Concise failure reason"}"# )); + assert!(prompt.contains(".sealos/template/index.yaml")); assert!(prompt.contains("Do not wrap the result line in Markdown")); } @@ -553,13 +596,13 @@ mod tests { "content": [ { "type": "text", - "text": "DEPLOYMENT_RESULT: {\"status\":\"succeeded\",\"image\":\"ghcr.io/wrong/image:tag\",\"message\":\"wrong\",\"error\":null}" + "text": "DEPLOYMENT_RESULT: {\"status\":\"succeeded\",\"image\":\"ghcr.io/wrong/image:tag\",\"template\":\"wrong\",\"message\":\"wrong\",\"error\":null}" } ] }, { "type": "agentMessage", - "text": "done\nDEPLOYMENT_RESULT: {\"status\":\"succeeded\",\"image\":\"ghcr.io/owner/repo:sha-abcdef0\",\"message\":\"Deployment image pushed to GHCR\",\"error\":null}" + "text": "done\nDEPLOYMENT_RESULT: {\"status\":\"succeeded\",\"image\":\"ghcr.io/owner/repo:sha-abcdef0\",\"template\":\"apiVersion: app.sealos.io/v1\\nkind: Template\\nmetadata:\\n name: owner-repo\\n\",\"message\":\"Deployment image pushed to GHCR\",\"error\":null}" } ] } @@ -574,6 +617,10 @@ mod tests { status.image.as_deref(), Some("ghcr.io/owner/repo:sha-abcdef0") ); + assert_eq!( + status.template.as_deref(), + Some("apiVersion: app.sealos.io/v1\nkind: Template\nmetadata:\n name: owner-repo\n") + ); } #[test] @@ -589,6 +636,7 @@ mod tests { assert_eq!(status.status, "running"); assert_eq!(status.image, None); + assert_eq!(status.template, None); assert_eq!(status.error, None); } @@ -625,7 +673,7 @@ mod tests { "items": [ { "type": "agentMessage", - "text": "DEPLOYMENT_RESULT: {\"status\":\"succeeded\",\"image\":null,\"message\":\"done\",\"error\":null}" + "text": "DEPLOYMENT_RESULT: {\"status\":\"succeeded\",\"image\":null,\"template\":\"apiVersion: app.sealos.io/v1\\nkind: Template\\n\",\"message\":\"done\",\"error\":null}" } ] } @@ -639,6 +687,56 @@ mod tests { assert!(status.error.as_deref().unwrap_or("").contains("GHCR image")); } + #[test] + fn deployment_status_requires_template_on_success() { + let thread = json!({ + "thread": { + "status": { "type": "idle" }, + "turns": [ + { + "status": "completed", + "items": [ + { + "type": "agentMessage", + "text": "DEPLOYMENT_RESULT: {\"status\":\"succeeded\",\"image\":\"ghcr.io/owner/repo:sha-abcdef0\",\"template\":null,\"message\":\"done\",\"error\":null}" + } + ] + } + ] + } + }); + + let status = brain_deployment_status_from_thread("thread-1", &thread); + + assert_eq!(status.status, "failed"); + assert!(status.error.as_deref().unwrap_or("").contains("template")); + } + + #[test] + fn deployment_status_rejects_template_on_failure() { + let thread = json!({ + "thread": { + "status": { "type": "idle" }, + "turns": [ + { + "status": "completed", + "items": [ + { + "type": "agentMessage", + "text": "DEPLOYMENT_RESULT: {\"status\":\"failed\",\"image\":null,\"template\":\"apiVersion: app.sealos.io/v1\\nkind: Template\\n\",\"message\":\"failed\",\"error\":\"build failed\"}" + } + ] + } + ] + } + }); + + let status = brain_deployment_status_from_thread("thread-1", &thread); + + assert_eq!(status.status, "failed"); + assert!(status.error.as_deref().unwrap_or("").contains("template")); + } + #[test] fn deployment_status_requires_ghcr_image_on_success() { let thread = json!({ @@ -650,7 +748,7 @@ mod tests { "items": [ { "type": "agentMessage", - "text": "DEPLOYMENT_RESULT: {\"status\":\"succeeded\",\"image\":\"docker.io/owner/repo:sha-abcdef0\",\"message\":\"done\",\"error\":null}" + "text": "DEPLOYMENT_RESULT: {\"status\":\"succeeded\",\"image\":\"docker.io/owner/repo:sha-abcdef0\",\"template\":\"apiVersion: app.sealos.io/v1\\nkind: Template\\n\",\"message\":\"done\",\"error\":null}" } ] } @@ -675,7 +773,7 @@ mod tests { "items": [ { "type": "agentMessage", - "text": "DEPLOYMENT_RESULT: {\"status\":\"succeeded\",\"image\":\"ghcr.io/owner/repo:sha-old\",\"message\":\"old\",\"error\":null}" + "text": "DEPLOYMENT_RESULT: {\"status\":\"succeeded\",\"image\":\"ghcr.io/owner/repo:sha-old\",\"template\":\"apiVersion: app.sealos.io/v1\\nkind: Template\\n\",\"message\":\"old\",\"error\":null}" } ] }, @@ -715,7 +813,7 @@ mod tests { "items": [ { "type": "agentMessage", - "text": "DEPLOYMENT_RESULT: {\"status\":\"succeeded\",\"image\":\"ghcr.io/owner/repo:sha-abcdef0\",\"message\":\"done\",\"error\":null}\nextra" + "text": "DEPLOYMENT_RESULT: {\"status\":\"succeeded\",\"image\":\"ghcr.io/owner/repo:sha-abcdef0\",\"template\":\"apiVersion: app.sealos.io/v1\\nkind: Template\\n\",\"message\":\"done\",\"error\":null}\nextra" } ] } diff --git a/tests/http_integration.rs b/tests/http_integration.rs index 91c95c2..fc1d1d0 100644 --- a/tests/http_integration.rs +++ b/tests/http_integration.rs @@ -155,6 +155,10 @@ fn gateway_session_thread_and_turn_http_flow_against_fake_app_server() { deployment_status.get("image").and_then(Value::as_str), Some("ghcr.io/owner/repo:sha-abcdef0") ); + assert_eq!( + deployment_status.get("template").and_then(Value::as_str), + Some("apiVersion: app.sealos.io/v1\nkind: Template\nmetadata:\n name: owner-repo\n") + ); let (status, resumed) = gateway.json_request( "POST", diff --git a/tests/support/fake_codex.rs b/tests/support/fake_codex.rs index 0937a84..8a1b6bf 100644 --- a/tests/support/fake_codex.rs +++ b/tests/support/fake_codex.rs @@ -108,7 +108,7 @@ fn resumed_thread_json() -> &'static str { } fn deployment_thread_json() -> &'static str { - r#"{"id":"thread-1","status":{"type":"idle"},"createdAt":1700000000,"turns":[{"status":"completed","items":[{"id":"deploy-user","type":"userMessage","content":[{"type":"text","text":"deploy user marker DEPLOYMENT_RESULT: {\"status\":\"succeeded\",\"image\":\"ghcr.io/wrong/image:tag\",\"message\":\"wrong\",\"error\":null}"}]},{"id":"deploy-assistant","type":"agentMessage","text":"Deployment image pushed to GHCR\nDEPLOYMENT_RESULT: {\"status\":\"succeeded\",\"image\":\"ghcr.io/owner/repo:sha-abcdef0\",\"message\":\"Deployment image pushed to GHCR\",\"error\":null}"}]}]}"# + r#"{"id":"thread-1","status":{"type":"idle"},"createdAt":1700000000,"turns":[{"status":"completed","items":[{"id":"deploy-user","type":"userMessage","content":[{"type":"text","text":"deploy user marker DEPLOYMENT_RESULT: {\"status\":\"succeeded\",\"image\":\"ghcr.io/wrong/image:tag\",\"template\":\"wrong\",\"message\":\"wrong\",\"error\":null}"}]},{"id":"deploy-assistant","type":"agentMessage","text":"Deployment image pushed to GHCR\nDEPLOYMENT_RESULT: {\"status\":\"succeeded\",\"image\":\"ghcr.io/owner/repo:sha-abcdef0\",\"template\":\"apiVersion: app.sealos.io/v1\\nkind: Template\\nmetadata:\\n name: owner-repo\\n\",\"message\":\"Deployment image pushed to GHCR\",\"error\":null}"}]}]}"# } fn send(stdout: &mut io::Stdout, message: &str) { From 881c0a2c3b0bcebc43a65907aafa83aa5a02d7d0 Mon Sep 17 00:00:00 2001 From: Che <30403707+Che-Zhu@users.noreply.github.com> Date: Wed, 20 May 2026 15:08:26 +0800 Subject: [PATCH 3/4] fix: harden brain deployment result parsing --- src/brain/deployments.rs | 108 ++++++++++++++++++++++++++++++-------- src/main.rs | 3 +- tests/http_integration.rs | 1 - 3 files changed, 88 insertions(+), 24 deletions(-) diff --git a/src/brain/deployments.rs b/src/brain/deployments.rs index 582e608..6a8213c 100644 --- a/src/brain/deployments.rs +++ b/src/brain/deployments.rs @@ -216,25 +216,18 @@ pub fn build_brain_deployment_prompt( repository: &str, branch: Option<&str>, github_token: &str, - skill_preinstalled: bool, ) -> String { let branch_instruction = branch .map(|branch| format!("Use branch `{branch}`.")) .unwrap_or_else(|| "Use the repository default branch.".to_string()); - let skill_instruction = if skill_preinstalled { - format!( - "The deployment skill has already been installed by the gateway runtime bootstrap. Use the {BRAIN_DEPLOYMENT_SKILL_TRIGGER} deployment workflow if it is available. If it is unavailable, perform the equivalent workflow: inspect the repository, generate or reuse a Dockerfile, verify the image build, publish the image to GHCR, generate a Sealos template, and report the pushed image reference plus the template content." - ) - } else { - format!( - r#"Mandatory first step: + let skill_instruction = format!( + r#"Mandatory first step: - Install the deployment skill before doing anything else: npx --yes skills add https://github.com/zjy365/seakills/tree/sandbox-skill-lite -y - If the install command fails, stop and return a failed `DEPLOYMENT_RESULT` with the install failure reason. After the skill is installed, use the {BRAIN_DEPLOYMENT_SKILL_TRIGGER} deployment workflow if it is available. If it is unavailable, perform the equivalent workflow: inspect the repository, generate or reuse a Dockerfile, verify the image build, publish the image to GHCR, generate a Sealos template, and report the pushed image reference plus the template content."# - ) - }; + ); format!( r#"You are running a repository deployment requested through Codex Gateway. @@ -323,7 +316,7 @@ fn brain_deployment_response_from_result( }; } let template = non_empty_optional(result.template); - if template.is_none() { + if !template.as_deref().is_some_and(is_valid_sealos_template) { return BrainDeploymentStatusResponse { thread_id: thread_id.to_string(), status: "failed".to_string(), @@ -417,6 +410,8 @@ fn find_brain_deployment_result(thread: &Value) -> BrainDeploymentResultState { Err(error) => return BrainDeploymentResultState::Invalid(error), } } + + return BrainDeploymentResultState::Missing; } } @@ -497,6 +492,27 @@ fn is_valid_ghcr_image(image: &str) -> bool { || rest.contains("@sha256:")) } +fn is_valid_sealos_template(template: &str) -> bool { + let template = template.trim(); + if template.is_empty() + || template.contains('\0') + || template == ".sealos/template/index.yaml" + || template.ends_with("/.sealos/template/index.yaml") + || template.ends_with(".sealos/template/index.yaml") + { + return false; + } + + let has_api_version = template + .lines() + .any(|line| line.trim_start().starts_with("apiVersion:")); + let has_kind = template + .lines() + .any(|line| line.trim_start().starts_with("kind:")); + + has_api_version && has_kind +} + fn thread_is_active(thread: &Value) -> bool { if status_value_is_active(thread.get("status")) { return true; @@ -553,7 +569,7 @@ mod tests { #[test] fn deployment_prompt_includes_exact_result_shapes() { - let prompt = build_brain_deployment_prompt("owner/repo", Some("main"), "ghp_secret", false); + let prompt = build_brain_deployment_prompt("owner/repo", Some("main"), "ghp_secret"); assert!(prompt.contains("Repository: owner/repo")); assert!(prompt.contains("Use branch `main`.")); @@ -573,15 +589,6 @@ mod tests { assert!(prompt.contains("Do not wrap the result line in Markdown")); } - #[test] - fn deployment_prompt_skips_skill_install_when_runtime_bootstrapped() { - let prompt = build_brain_deployment_prompt("owner/repo", None, "ghp_secret", true); - - assert!(prompt.contains("deployment skill has already been installed")); - assert!(!prompt.contains("npx --yes skills add")); - assert!(!prompt.contains("Mandatory first step")); - } - #[test] fn deployment_status_ignores_user_prompt_markers() { let thread = json!({ @@ -712,6 +719,31 @@ mod tests { assert!(status.error.as_deref().unwrap_or("").contains("template")); } + #[test] + fn deployment_status_rejects_template_path_on_success() { + let thread = json!({ + "thread": { + "status": { "type": "idle" }, + "turns": [ + { + "status": "completed", + "items": [ + { + "type": "agentMessage", + "text": "DEPLOYMENT_RESULT: {\"status\":\"succeeded\",\"image\":\"ghcr.io/owner/repo:sha-abcdef0\",\"template\":\".sealos/template/index.yaml\",\"message\":\"done\",\"error\":null}" + } + ] + } + ] + } + }); + + let status = brain_deployment_status_from_thread("thread-1", &thread); + + assert_eq!(status.status, "failed"); + assert!(status.error.as_deref().unwrap_or("").contains("template")); + } + #[test] fn deployment_status_rejects_template_on_failure() { let thread = json!({ @@ -802,6 +834,40 @@ mod tests { ); } + #[test] + fn deployment_status_requires_result_in_final_assistant_message() { + let thread = json!({ + "thread": { + "status": { "type": "idle" }, + "turns": [ + { + "status": "completed", + "items": [ + { + "type": "agentMessage", + "text": "DEPLOYMENT_RESULT: {\"status\":\"succeeded\",\"image\":\"ghcr.io/owner/repo:sha-old\",\"template\":\"apiVersion: app.sealos.io/v1\\nkind: Template\\n\",\"message\":\"old\",\"error\":null}" + } + ] + }, + { + "status": "completed", + "items": [ + { + "type": "agentMessage", + "text": "done without structured result" + } + ] + } + ] + } + }); + + let status = brain_deployment_status_from_thread("thread-1", &thread); + + assert_eq!(status.status, "failed"); + assert!(status.error.as_deref().unwrap_or("").contains("not found")); + } + #[test] fn deployment_status_requires_result_line_to_be_final() { let thread = json!({ diff --git a/src/main.rs b/src/main.rs index bf7004e..7c0c0c1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -304,8 +304,7 @@ async fn create_brain_deployment( )); }; - let prompt = - build_brain_deployment_prompt(&repository, branch.as_deref(), &github_token, false); + let prompt = build_brain_deployment_prompt(&repository, branch.as_deref(), &github_token); if let Err(error) = state .session_manager .send_prompt(&session_id, &prompt) diff --git a/tests/http_integration.rs b/tests/http_integration.rs index fc1d1d0..c90c410 100644 --- a/tests/http_integration.rs +++ b/tests/http_integration.rs @@ -222,7 +222,6 @@ impl GatewayProcess { .env("CODEX_GATEWAY_CWD", std::env::current_dir().expect("cwd")) .env("CODEX_GATEWAY_CODEX_HOME", &codex_home) .env("CODEX_GATEWAY_MAX_SESSIONS", "4") - .env("CODEX_GATEWAY_DEPLOYMENT_TIMEOUT_MS", "1") .env("CODEX_GATEWAY_SESSION_TTL_MS", "60000") .env("CODEX_GATEWAY_SESSION_SWEEP_INTERVAL_MS", "60000") .env_remove("CODEX_GATEWAY_MODEL") From 0f9ab80d093c86d7a4ed79eb7ebf0b8447864c0f Mon Sep 17 00:00:00 2001 From: Che <30403707+Che-Zhu@users.noreply.github.com> Date: Wed, 20 May 2026 15:21:30 +0800 Subject: [PATCH 4/4] fix: satisfy brain deployment registry clippy --- src/brain/deployments.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/brain/deployments.rs b/src/brain/deployments.rs index 6a8213c..ede38a4 100644 --- a/src/brain/deployments.rs +++ b/src/brain/deployments.rs @@ -127,6 +127,12 @@ impl BrainDeploymentRegistry { } } +impl Default for BrainDeploymentRegistry { + fn default() -> Self { + Self::new() + } +} + impl BrainDeploymentRecord { fn new( thread_id: String,