diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index a88dd8401c..4bdd20f756 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -266,6 +266,11 @@ jobs: node-version: 22 cache: pnpm + - name: Setup Python (bundled loopx CLI) + uses: actions/setup-python@v5 + with: + python-version: '3.13' + - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable with: diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index e80dd0751b..c8644cff8d 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -153,6 +153,11 @@ jobs: node-version: 22 cache: pnpm + - name: Setup Python (bundled loopx CLI) + uses: actions/setup-python@v5 + with: + python-version: '3.13' + - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable with: diff --git a/.gitignore b/.gitignore index 6b2f0197e4..41daac4568 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,6 @@ external/ /.flashgrep-index-engine/ .design/ + +# Generated by scripts/build-loopx.mjs (never commit the compiled binary) +src/apps/desktop/resources/loopx/ diff --git a/BitFun-Installer/src-tauri/Cargo.lock b/BitFun-Installer/src-tauri/Cargo.lock new file mode 100644 index 0000000000..ebb2922961 --- /dev/null +++ b/BitFun-Installer/src-tauri/Cargo.lock @@ -0,0 +1,5804 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitfun-agent-stream" +version = "0.2.13" +dependencies = [ + "anyhow", + "async-trait", + "bitfun-events", + "futures", + "log", + "serde", + "serde_json", + "tokio", + "tokio-util", + "uuid", +] + +[[package]] +name = "bitfun-ai-adapters" +version = "0.2.13" +dependencies = [ + "anyhow", + "async-trait", + "bitfun-agent-stream", + "bitfun-core-types", + "chrono", + "eventsource-stream", + "futures", + "log", + "reqwest 0.13.4", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tokio-util", + "urlencoding", +] + +[[package]] +name = "bitfun-core-types" +version = "0.2.13" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "bitfun-events" +version = "0.2.13" +dependencies = [ + "anyhow", + "async-trait", + "bitfun-core-types", + "chrono", + "log", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "bitfun-installer" +version = "0.2.13" +dependencies = [ + "anyhow", + "bitfun-ai-adapters", + "chrono", + "dirs 5.0.1", + "eventsource-stream", + "flate2", + "futures", + "log", + "mslnk", + "reqwest 0.12.28", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tokio", + "tokio-stream", + "urlencoding", + "winreg 0.52.0", + "zip", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.4+spec-1.1.0", + "vswhom", + "winreg 0.55.0", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "eventsource-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" +dependencies = [ + "futures-core", + "nom", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "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]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[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 = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mslnk" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86c97310150b7f496a93f31690da7822b99d95ff68ca9d30fb09d3ad54375c76" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "log", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "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.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "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", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +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 2.0.119", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.1", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni 0.21.1", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs 6.0.0", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni 0.21.1", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.20", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs 6.0.0", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.20", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.20", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni 0.21.1", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni 0.21.1", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.4+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[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 = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +dependencies = [ + "crossbeam-channel", + "dirs 6.0.0", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "untrusted" +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", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[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.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +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 = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.20", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs 6.0.0", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni 0.21.1", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +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 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "aes", + "byteorder", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "flate2", + "hmac", + "pbkdf2", + "sha1", + "time", + "zstd", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.11.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "5.0.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/MiniApp/Demo/git-graph/README.md b/MiniApp/Demo/git-graph/README.md index c2115c82d6..67b3d7b2f2 100644 --- a/MiniApp/Demo/git-graph/README.md +++ b/MiniApp/Demo/git-graph/README.md @@ -28,7 +28,7 @@ This demo showcases BitFun MiniApp's full-stack collaboration capability — spe 1. **UI → Bridge**: `app.call('git.log', { cwd, maxCount })` etc. via `window.app` (JSON-RPC) 2. **Bridge → Tauri**: postMessage intercepted by the host `useMiniAppBridge`, which calls `miniapp_worker_call` 3. **Tauri → Worker**: Rust writes the request to Worker stdin (JSON-RPC) -4. **Worker**: `worker_host.js` loads `source/worker.js`; exported handlers are invoked — primarily `git.graphData` (returns commits + refs + stashes + uncommitted in one response), plus `git.show`, `git.checkout`, `git.merge`, `git.push`, `git.stashPush`, and 20+ other methods — all backed by the `simple-git` npm package +4. **Worker**: `worker_host.cjs` loads `source/worker.js`; exported handlers are invoked — primarily `git.graphData` (returns commits + refs + stashes + uncommitted in one response), plus `git.show`, `git.checkout`, `git.merge`, `git.push`, `git.stashPush`, and 20+ other methods — all backed by the `simple-git` npm package 5. **Worker → Tauri → Bridge → UI**: response travels back via stderr → Rust → postMessage to iframe → UI refreshes graph and detail panel ### Directory Structure @@ -117,7 +117,7 @@ miniapps/git-graph/ 1. **UI → Bridge**:`app.call('git.log', { cwd, maxCount })` 等通过 `window.app` 发起 RPC 2. **Bridge → Tauri**:postMessage 被宿主 `useMiniAppBridge` 接收,调用 `miniapp_worker_call` 3. **Tauri → Worker**:Rust 将请求写入 Worker 进程 stdin(JSON-RPC) -4. **Worker**:`worker_host.js` 加载本目录 `source/worker.js`,其导出的处理函数被调用 — 主要是 `git.graphData`(一次返回提交 + refs + stash + 未提交变更),以及 `git.show`、`git.checkout`、`git.merge`、`git.push`、`git.stashPush` 等 20+ 个方法 — 均基于 `simple-git` npm 包 +4. **Worker**:`worker_host.cjs` 加载本目录 `source/worker.js`,其导出的处理函数被调用 — 主要是 `git.graphData`(一次返回提交 + refs + stash + 未提交变更),以及 `git.show`、`git.checkout`、`git.merge`、`git.push`、`git.stashPush` 等 20+ 个方法 — 均基于 `simple-git` npm 包 5. **Worker → Tauri → Bridge → UI**:响应经 stderr 回传 Rust,再 postMessage 回 iframe,UI 更新图谱与详情 ### 目录结构 diff --git a/MiniApp/Skills/miniapp-dev/SKILL.md b/MiniApp/Skills/miniapp-dev/SKILL.md index 8e291f9b4f..89c1464caf 100644 --- a/MiniApp/Skills/miniapp-dev/SKILL.md +++ b/MiniApp/Skills/miniapp-dev/SKILL.md @@ -121,10 +121,10 @@ src/web-ui/src/flow_chat/tool-cards/MiniAppToolDisplay.tsx # InitMiniAppDispla ### Worker 宿主 ``` -src/apps/desktop/resources/worker_host.js +src/apps/desktop/resources/worker_host.cjs ``` -Node/Bun 标准脚本:从 argv 读策略 JSON,stdin 收 RPC、stderr 回响应,内置 fs/shell/net/os/storage dispatch + 加载用户 `source/worker.js` 自定义方法。 +Node/Bun 标准脚本:从 `BITFUN_WORKER_POLICY` 环境变量读策略 JSON(argv[2] 仅作手动运行兜底),stdin 收 RPC、stderr 回响应,内置 fs/shell/net/os/storage dispatch + 加载用户 `source/worker.js` 自定义方法。 ## MiniApp 数据模型 (V2) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 284b7f2ba3..f1237b0266 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -52,3 +52,41 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## loopx + +- Project: loopx +- Source: https://github.com/huangruiteng/loopx +- License: MIT +- Copyright: Copyright (c) 2026 LoopX contributors + +BitFun bundles a compiled, self-contained build of the loopx CLI as a desktop +sidecar resource (`resources/loopx/`). It powers the built-in bitfun-loopx +MiniApp's issue-fixing loop and is built at packaging time by +`scripts/build-loopx.mjs` from the pinned upstream release recorded in +`resources/loopx/manifest.json` (version, commit, content hash, and build +toolchain). The upstream license text and trademark policy ship alongside the +binary as `resources/loopx/LICENSE` and `resources/loopx/TRADEMARKS.md` in +binary release packages. The `loopx` name is used descriptively to refer to +the upstream project; bitfun-loopx is a third-party integration and is not a +LoopX project release. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/package.json b/package.json index f3a45ca6ec..7f3fd18674 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "verify:webkit-compatibility": "node scripts/verify-webkit-compatibility.cjs", "verify:webkit-compatibility:test": "node --test scripts/verify-webkit-compatibility.test.mjs", "build:web": "pnpm run appearance:contract-audit && node scripts/build-web-parallel.mjs && pnpm run verify:monaco-assets && pnpm run verify:webkit-compatibility", + "build:loopx": "node scripts/build-loopx.mjs", "build:mobile-web": "pnpm --dir src/mobile-web build", "build:miniapp-market": "pnpm --dir src/miniapp-market-web build", "type-check:miniapp-market": "pnpm --dir src/miniapp-market-web type-check", diff --git a/scripts/build-loopx.mjs b/scripts/build-loopx.mjs new file mode 100644 index 0000000000..c0c220179e --- /dev/null +++ b/scripts/build-loopx.mjs @@ -0,0 +1,170 @@ +#!/usr/bin/env node +// Build the bundled loopx CLI for the BitFun desktop installer. +// +// Runs at BUILD time only (CI / packaging), never on user machines: fetches the +// pinned loopx source, compiles a self-contained onefile binary with +// PyInstaller, and stages it under src/apps/desktop/resources/loopx/ together +// with the compliance artifacts (MIT LICENSE, TRADEMARKS.md, provenance +// manifest). The desktop bundles that directory as a sidecar resource and the +// bitfun-loopx MiniApp worker prefers the bundled binary at runtime, so end +// users need neither Python nor git nor network access to use loopx. +// +// loopx is MIT (Copyright (c) 2026 LoopX contributors), pure-stdlib Python +// >= 3.11; PyInstaller's bootloader exception permits the bundled binary. + +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Keep in sync with LOOPX_VENDOR_REF in the bitfun-loopx MiniApp worker.js: +// loopx's CLI JSON contract is the app's interface surface, so the bundled +// binary and the runtime vendor fallback must pin the same version. +const LOOPX_VERSION = process.env.LOOPX_VERSION || 'v0.2.13'; +const LOOPX_REPO = 'https://github.com/huangruiteng/loopx.git'; +const OUT_DIR = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + 'src', + 'apps', + 'desktop', + 'resources', + 'loopx', +); + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + buildLoopx().catch((err) => { + console.error(`build-loopx failed: ${err.message}`); + process.exit(1); + }); +}function sh(cmd, args, opts = {}) { + execFileSync(cmd, args, { stdio: 'inherit', ...opts }); +} + +function shOut(cmd, args, opts = {}) { + return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts }) + .toString() + .trim(); +} + +function sha256Of(file) { + return createHash('sha256').update(readFileSync(file)).digest('hex'); +} + +function pickPython() { + for (const candidate of [process.env.PYTHON, 'python', 'python3'].filter(Boolean)) { + try { + const version = shOut(candidate, ['--version']); + const m = version.match(/Python\s+(\d+)\.(\d+)/); + if (m && (Number(m[1]) > 3 || (Number(m[1]) === 3 && Number(m[2]) >= 11))) { + return { exe: candidate, version: version.replace(/\s+/g, ' ').trim() }; + } + console.warn(`build-loopx: ${candidate} is ${version} (Python >= 3.11 required), skipping`); + } catch { + // not installed / not on PATH + } + } + throw new Error('Python >= 3.11 not found (set PYTHON to a usable interpreter)'); +} + +export async function buildLoopx({ + version = LOOPX_VERSION, + outDir = OUT_DIR, +} = {}) { + const python = pickPython(); + console.log(`build-loopx: python ${python.version} (${python.exe})`); + try { + shOut('git', ['--version']); + } catch { + throw new Error('git not found on PATH'); + } + + const work = mkdtempSync(path.join(tmpdir(), 'loopx-build-')); + const src = path.join(work, 'src'); + const venv = path.join(work, 'venv'); + const dist = path.join(work, 'dist'); + try { + console.log(`build-loopx: cloning ${LOOPX_REPO} @ ${version}`); + sh('git', ['clone', '--depth', '1', '--branch', version, LOOPX_REPO, src]); + const commit = shOut('git', ['-C', src, 'rev-parse', 'HEAD']); + const described = shOut('git', ['-C', src, 'describe', '--tags', '--exact-match']); + if (described !== version) { + throw new Error(`pinned tag mismatch: expected ${version}, checkout is ${described}`); + } + if (!existsSync(path.join(src, 'LICENSE')) || !existsSync(path.join(src, 'loopx', 'cli.py'))) { + throw new Error('checkout is missing LICENSE or loopx/cli.py'); + } + + console.log('build-loopx: creating build venv and installing PyInstaller'); + sh(python.exe, ['-m', 'venv', venv]); + const pip = process.platform === 'win32' + ? path.join(venv, 'Scripts', 'pip.exe') + : path.join(venv, 'bin', 'pip'); + const pyinstaller = process.platform === 'win32' + ? path.join(venv, 'Scripts', 'pyinstaller.exe') + : path.join(venv, 'bin', 'pyinstaller'); + sh(pip, ['install', '--disable-pip-version-check', '--quiet', 'pyinstaller']); + + const entry = path.join(src, '_loopx_bundle_entry.py'); + writeFileSync(entry, 'from loopx.cli import main\nraise SystemExit(main())\n', 'utf8'); + + console.log('build-loopx: compiling onefile binary (PyInstaller)'); + sh(pyinstaller, [ + '--onefile', + '--name', 'loopx', + '--clean', + '--noconfirm', + '--distpath', dist, + '--workpath', path.join(work, 'build'), + '--specpath', path.join(work, 'build'), + path.basename(entry), + ], { cwd: src }); + + const binary = path.join(dist, process.platform === 'win32' ? 'loopx.exe' : 'loopx'); + if (!existsSync(binary)) throw new Error(`PyInstaller produced no binary at ${binary}`); + + console.log('build-loopx: staging into', outDir); + mkdirSync(outDir, { recursive: true }); + copyFileSync(binary, path.join(outDir, path.basename(binary))); + copyFileSync(path.join(src, 'LICENSE'), path.join(outDir, 'LICENSE')); + copyFileSync(path.join(src, 'TRADEMARKS.md'), path.join(outDir, 'TRADEMARKS.md')); + + const pyinstallerVersion = shOut(pyinstaller, ['--version']); + const manifest = { + schema_version: 1, + name: 'loopx', + version, + source: LOOPX_REPO.replace(/\.git$/, ''), + commit, + license: 'MIT', + copyright: 'Copyright (c) 2026 LoopX contributors', + sha256: `sha256:${sha256Of(path.join(outDir, path.basename(binary)))}`, + built_with: { + python: python.version, + pyinstaller: pyinstallerVersion, + }, + built_at: new Date().toISOString(), + }; + writeFileSync( + path.join(outDir, 'manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n`, + 'utf8', + ); + + const sizeMb = (statSync(path.join(outDir, path.basename(binary))).size / 1048576).toFixed(1); + console.log(`build-loopx: done — ${path.join(outDir, path.basename(binary))} (${sizeMb} MiB, loopx ${version} @ ${commit})`); + } finally { + rmSync(work, { recursive: true, force: true }); + } +} diff --git a/scripts/desktop-tauri-build.mjs b/scripts/desktop-tauri-build.mjs index d2ab567a26..3e11cd8d91 100644 --- a/scripts/desktop-tauri-build.mjs +++ b/scripts/desktop-tauri-build.mjs @@ -14,6 +14,7 @@ import { writeFileSync, } from 'fs'; import { ensureFlashgrepBinary } from './prepare-flashgrep-resource.mjs'; +import { buildLoopx } from './build-loopx.mjs'; import { extractProductConfigArg } from './product-customization/cli.mjs'; import { productBuildEnvironment } from './product-customization/projections.mjs'; import { resolveProductDefinition } from './product-customization/resolver.mjs'; @@ -52,6 +53,7 @@ async function main() { desktopDir, ); process.env.FLASHGREP_DAEMON_BIN = flashgrepBinary; + const loopxResourceDir = await prepareBundledLoopx(forward, desktopDir); // Tauri CLI reads CI and rejects numeric "1" (common in CI providers). process.env.CI = 'true'; if (process.platform === 'darwin' && requestsDmgBundle(forward)) { @@ -63,6 +65,7 @@ async function main() { const tauriConfig = prepareTauriConfig(join(desktopDir, 'tauri.conf.json'), { desktopDir, flashgrepBinary, + loopxResourceDir, resolution, releaseChannel, }); @@ -217,7 +220,7 @@ export function prepareMacOSFlashgrepForSigning( export function prepareTauriConfig( baseConfigPath, - { desktopDir, flashgrepBinary, resolution, releaseChannel } + { desktopDir, flashgrepBinary, loopxResourceDir, resolution, releaseChannel } ) { const config = JSON.parse(readFileSync(baseConfigPath, 'utf8')); if (resolution) { @@ -229,6 +232,7 @@ export function prepareTauriConfig( config.identifier = resolution.assembly.bundleId; } injectTargetFlashgrepResource(config, desktopDir, flashgrepBinary); + injectLoopxResource(config, loopxResourceDir); // The DeepSeek bridge is not a compile-time resource: cargo check and // desktop:dev must not require packages/dsh-acp/dist-profile. Official // packaging injects it here; frontend:build-all (beforeBuildCommand) @@ -320,6 +324,37 @@ function injectTargetFlashgrepResource(config, desktopDir, flashgrepBinary) { }; } +// The compiled loopx CLI sidecar is staged under resources/loopx/ and injected +// only when a real bundle is requested. tauri-build validates every resource +// path at cargo-build time, so a missing directory must never appear in the +// generated config (the bitfun-loopx MiniApp falls back to vendor/pip at +// runtime when the sidecar is absent, e.g. in dev builds). +function injectLoopxResource(config, loopxResourceDir) { + const resources = { ...(config.bundle?.resources || {}) }; + delete resources['resources/loopx/']; + if (loopxResourceDir) { + resources['resources/loopx/'] = 'resources/loopx/'; + } + config.bundle = { + ...(config.bundle || {}), + resources, + }; +} + +async function prepareBundledLoopx(forwardArgs, desktopDir) { + if (forwardArgs.includes('--no-bundle')) { + return null; + } + console.log('[tauri-build] Building the bundled loopx CLI sidecar (scripts/build-loopx.mjs)'); + await buildLoopx(); + const loopxDir = join(desktopDir, 'resources', 'loopx'); + const bin = join(loopxDir, process.platform === 'win32' ? 'loopx.exe' : 'loopx'); + if (!existsSync(bin)) { + throw new Error(`bundled loopx CLI missing after build at ${bin}`); + } + return loopxDir; +} + function bundledFlashgrepResources(primaryBinary) { const binaries = [primaryBinary]; diff --git a/scripts/desktop-tauri-build.test.mjs b/scripts/desktop-tauri-build.test.mjs index 1dd4350ac6..7f3e4f09a4 100644 --- a/scripts/desktop-tauri-build.test.mjs +++ b/scripts/desktop-tauri-build.test.mjs @@ -332,7 +332,7 @@ test('official packaging injects the DeepSeek profile resource', () => { mkdirSync(fixture, { recursive: true }); const baseConfig = join(fixture, 'tauri.conf.json'); writeFileSync(baseConfig, JSON.stringify({ - bundle: { resources: { 'resources/worker_host.js': 'resources/worker_host.js' } }, + bundle: { resources: { 'resources/worker_host.cjs': 'resources/worker_host.cjs' } }, })); try { const generated = prepareTauriConfig(baseConfig, { @@ -345,8 +345,8 @@ test('official packaging injects the DeepSeek profile resource', () => { 'resources/dsh-profile', ); assert.equal( - config.bundle.resources['resources/worker_host.js'], - 'resources/worker_host.js', + config.bundle.resources['resources/worker_host.cjs'], + 'resources/worker_host.cjs', ); } finally { rmSync(fixture, { force: true, recursive: true }); diff --git a/src/apps/cli/src/peer_host/deny.rs b/src/apps/cli/src/peer_host/deny.rs index 16701bec1c..5a94695071 100644 --- a/src/apps/cli/src/peer_host/deny.rs +++ b/src/apps/cli/src/peer_host/deny.rs @@ -113,6 +113,12 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ // belongs to the person at this machine, so refuse it explicitly rather // than relying on the command being unimplemented here. "git_trust_repository", + "miniapp_loopx_attach", + "miniapp_loopx_list_models", + "miniapp_loopx_resolve_intake", + "miniapp_loopx_create_task", + "miniapp_loopx_action", + "miniapp_loopx_events_since", ]; /// Desktop IDE surfaces that CLI Peer Host does not implement. diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index ec66a94562..76d9aed24a 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -23,8 +23,8 @@ bitfun-core = { path = "../../crates/assembly/core", features = ["product-full"] bitfun-relay-service = { path = "../../crates/services/relay-service" } bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime", features = ["agent-runtime"] } bitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports", features = ["agent-api", "permission", "workspace-ports"] } -bitfun-product-domains = { path = "../../crates/contracts/product-domains", features = ["appearance-market"] } -bitfun-services-integrations = { path = "../../crates/services/services-integrations", features = ["canvas-runtime", "miniapp-market", "speech"] } +bitfun-product-domains = { path = "../../crates/contracts/product-domains", features = ["appearance-market", "miniapp"] } +bitfun-services-integrations = { path = "../../crates/services/services-integrations", features = ["canvas-runtime", "miniapp-loopx", "miniapp-market", "speech"] } bitfun-core-types = { path = "../../crates/contracts/core-types" } bitfun-agent-tools = { path = "../../crates/execution/tool-contracts", features = ["element-token"] } bitfun-transport = { path = "../../crates/adapters/transport", features = ["tauri-adapter"] } diff --git a/src/apps/desktop/resources/worker_host.js b/src/apps/desktop/resources/worker_host.cjs similarity index 94% rename from src/apps/desktop/resources/worker_host.js rename to src/apps/desktop/resources/worker_host.cjs index 4e9b4b4f63..827b218ab5 100644 --- a/src/apps/desktop/resources/worker_host.js +++ b/src/apps/desktop/resources/worker_host.cjs @@ -5,7 +5,7 @@ * stderr: JSON-RPC responses (one per line) * stdout: user console.log (forwarded to host) * - * Usage: node worker_host.js '' + * Usage: node worker_host.cjs (policy via BITFUN_WORKER_POLICY env; argv[2] accepted for manual runs) * Cwd: MiniApp app directory (contains source/worker.js, package.json, storage.json) */ @@ -15,7 +15,10 @@ const { exec: execCallback } = require('child_process'); const { promisify } = require('util'); const execAsync = promisify(execCallback); -const policy = JSON.parse(process.argv[2] || '{}'); +// Policy arrives through BITFUN_WORKER_POLICY (env) because Windows argv +// parsing does not round-trip multi-line JSON reliably. argv[2] stays as a +// convenience for manual runs. +const policy = JSON.parse(process.env.BITFUN_WORKER_POLICY || process.argv[2] || '{}'); const appDir = process.cwd(); const storagePath = path.join(appDir, 'storage.json'); diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index 8b750a771a..f7343081ab 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -184,15 +184,31 @@ impl AppState { let worker_host_path = match resolve_worker_host_path() { Some(p) => { - log::info!("Resolved worker_host.js at: {}", p.display()); + log::info!("Resolved worker host at: {}", p.display()); p } None => { log::warn!( - "worker_host.js not found in any candidate location; \ + "worker host not found in any candidate location; \ MiniApp Workers will not start" ); - std::path::PathBuf::from("worker_host.js") + std::path::PathBuf::from("worker_host.cjs") + } + }; + // The bitfun-loopx MiniApp prefers a bundled, compiled loopx CLI + // sidecar (scripts/build-loopx.mjs, shipped via bundle.resources). + // Export its resource directory to the JS workers when present; the + // MiniApp falls back to vendor/pip acquisition when absent (e.g. dev). + let loopx_resource_dir = match resolve_bundled_loopx_dir() { + Some(dir) => { + log::info!("Resolved bundled loopx CLI resource dir: {}", dir.display()); + Some(dir) + } + None => { + log::info!( + "Bundled loopx CLI not found; bitfun-loopx will use vendor/pip acquisition" + ); + None } }; let speech_service = Arc::new(SpeechService::new(SpeechStoragePaths::new( @@ -200,11 +216,11 @@ impl AppState { path_manager.speech_model_downloads_dir(), path_manager.speech_input_temp_dir(), ))); - let js_worker_pool = JsWorkerPool::new(path_manager, worker_host_path) + let js_worker_pool = JsWorkerPool::new(path_manager, worker_host_path, loopx_resource_dir) .ok() .map(Arc::new); if js_worker_pool.is_none() { - log::warn!("JsWorkerPool not initialized (missing worker_host.js or no Bun/Node)"); + log::warn!("JsWorkerPool not initialized (missing worker host or no Bun/Node)"); } let statistics = Arc::new(RwLock::new(AppStatistics { @@ -549,48 +565,51 @@ impl AppState { } } -/// Try every layout we know about for `worker_host.js`, dev or bundled: -/// 1. `CARGO_MANIFEST_DIR/resources/worker_host.js` — `cargo run` / `tauri dev`. -/// 2. `/resources/worker_host.js` — generic side-by-side bundle. -/// 3. `/../Resources/resources/worker_host.js` — macOS `.app` (Tauri +/// Try every layout we know about for `worker_host.cjs`, dev or bundled: +/// 1. `CARGO_MANIFEST_DIR/resources/worker_host.cjs` — `cargo run` / `tauri dev`. +/// 2. `/resources/worker_host.cjs` — generic side-by-side bundle. +/// 3. `/../Resources/resources/worker_host.cjs` — macOS `.app` (Tauri /// copies bundle.resources into `Contents/Resources/`). -/// 4. `/../Resources/worker_host.js` — flat macOS layout fallback. -/// 5. `/../lib//resources/worker_host.js` — typical Linux deb/AppImage. -/// 6. `/../share//resources/worker_host.js` — alt Linux layout. +/// 4. `/../Resources/worker_host.cjs` — flat macOS layout fallback. +/// 5. `/../lib//resources/worker_host.cjs` — typical Linux deb/AppImage. +/// 6. `/../share//resources/worker_host.cjs` — alt Linux layout. +/// `.cjs` (not `.js`): the host is CommonJS and must stay that way regardless of +/// the nearest `package.json` ("type": "module" in this repo's root would make +/// Node treat a `.js` host as ESM and crash on `require`). fn resolve_worker_host_path() -> Option { let mut candidates: Vec = Vec::new(); candidates.push( std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("resources") - .join("worker_host.js"), + .join("worker_host.cjs"), ); if let Ok(exe) = std::env::current_exe() { if let Some(exe_dir) = exe.parent() { - candidates.push(exe_dir.join("resources").join("worker_host.js")); + candidates.push(exe_dir.join("resources").join("worker_host.cjs")); if let Some(parent) = exe_dir.parent() { candidates.push( parent .join("Resources") .join("resources") - .join("worker_host.js"), + .join("worker_host.cjs"), ); - candidates.push(parent.join("Resources").join("worker_host.js")); + candidates.push(parent.join("Resources").join("worker_host.cjs")); if let Some(bin_name) = exe.file_name().and_then(|s| s.to_str()) { candidates.push( parent .join("lib") .join(bin_name) .join("resources") - .join("worker_host.js"), + .join("worker_host.cjs"), ); candidates.push( parent .join("share") .join(bin_name) .join("resources") - .join("worker_host.js"), + .join("worker_host.cjs"), ); } } @@ -599,3 +618,44 @@ fn resolve_worker_host_path() -> Option { candidates.into_iter().find(|p| p.exists()) } + +/// Resolve the directory hosting the bundled, compiled loopx CLI sidecar +/// (built by `scripts/build-loopx.mjs`, shipped via `bundle.resources`). The +/// layouts mirror `resolve_worker_host_path`, with the platform binary name +/// under a `loopx/` subdirectory. Returns the resource directory so the +/// worker pool can export it as `BITFUN_RESOURCE_DIR`. +pub(crate) fn resolve_bundled_loopx_dir() -> Option { + let bin_name = if cfg!(windows) { "loopx.exe" } else { "loopx" }; + let mut candidates: Vec = Vec::new(); + + candidates.push( + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("resources") + .join("loopx"), + ); + + if let Ok(exe) = std::env::current_exe() { + if let Some(exe_dir) = exe.parent() { + candidates.push(exe_dir.join("resources").join("loopx")); + if let Some(parent) = exe_dir.parent() { + candidates.push(parent.join("Resources").join("resources").join("loopx")); + candidates.push(parent.join("Resources").join("loopx")); + if let Some(bin) = exe.file_name().and_then(|s| s.to_str()) { + candidates.push(parent.join("lib").join(bin).join("resources").join("loopx")); + candidates.push( + parent + .join("share") + .join(bin) + .join("resources") + .join("loopx"), + ); + } + } + } + } + + candidates + .into_iter() + .find(|dir| dir.join(bin_name).exists()) + .map(|dir| dir.parent().map(|p| p.to_path_buf()).unwrap_or(dir)) +} diff --git a/src/apps/desktop/src/api/miniapp_loopx_api.rs b/src/apps/desktop/src/api/miniapp_loopx_api.rs new file mode 100644 index 0000000000..6ca804099b --- /dev/null +++ b/src/apps/desktop/src/api/miniapp_loopx_api.rs @@ -0,0 +1,247 @@ +use super::app_state::AppState; +use bitfun_core::miniapp::ai_bridge::{ + available_models_for_permissions, MiniAppAiModelDescriptor, MiniAppAiModelInfo, +}; +use bitfun_core::miniapp::{loopx::LoopxController, MiniAppCustomizationOriginKind, BUILTIN_APPS}; +use bitfun_core::service::config::types::GlobalConfig; +use bitfun_product_domains::miniapp::builtin::builtin_source_matches; +use bitfun_product_domains::miniapp::loopx::{ + LoopxActionRequest, LoopxActionResponse, LoopxAttachRequest, LoopxAttachResponse, + LoopxCreateTaskRequest, LoopxCreateTaskResponse, LoopxEventsSinceRequest, + LoopxEventsSinceResponse, LoopxExecutionDomain, LoopxExecutionSupport, + LoopxResolveIntakeRequest, LoopxResolveIntakeResponse, LOOPX_BUILTIN_APP_ID, +}; +use serde::Deserialize; +use std::sync::Arc; +use tauri::State; + +pub const LOOPX_UNSUPPORTED_EXECUTION_DOMAIN: &str = "unsupported_execution_domain"; + +pub struct LoopxControllerState { + pub controller: Arc, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppLoopxAttachRequest { + pub app_id: String, + #[serde(flatten)] + pub input: LoopxAttachRequest, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppLoopxResolveIntakeRequest { + pub app_id: String, + #[serde(flatten)] + pub input: LoopxResolveIntakeRequest, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppLoopxCreateTaskRequest { + pub app_id: String, + #[serde(flatten)] + pub input: LoopxCreateTaskRequest, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppLoopxActionRequest { + pub app_id: String, + #[serde(flatten)] + pub input: LoopxActionRequest, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppLoopxEventsSinceRequest { + pub app_id: String, + #[serde(flatten)] + pub input: LoopxEventsSinceRequest, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppLoopxListModelsRequest { + pub app_id: String, +} + +async fn authorize_builtin(state: &AppState, app_id: &str) -> Result<(), String> { + if app_id != LOOPX_BUILTIN_APP_ID { + return Err("LoopX controller is available only to the built-in LoopX MiniApp".to_string()); + } + let builtin = BUILTIN_APPS + .iter() + .find(|app| app.id == LOOPX_BUILTIN_APP_ID) + .ok_or_else(|| "Built-in LoopX bundle is unavailable".to_string())?; + let app = state + .miniapp_manager + .get(app_id) + .await + .map_err(|error| format!("Failed to load built-in LoopX MiniApp: {error}"))?; + if !builtin_source_matches(&app.source, builtin) { + return Err("LoopX controller is disabled for modified MiniApp content".to_string()); + } + if let Some(metadata) = state + .miniapp_manager + .load_customization_metadata(app_id) + .await + .map_err(|error| format!("Failed to load LoopX customization metadata: {error}"))? + { + if metadata.local_override + || metadata.origin.kind != MiniAppCustomizationOriginKind::Builtin + || metadata.origin.builtin_id.as_deref() != Some(LOOPX_BUILTIN_APP_ID) + { + return Err("LoopX controller is disabled for a local MiniApp override".to_string()); + } + } + Ok(()) +} + +async fn is_remote_workspace(state: &AppState) -> bool { + state.remote_workspace.read().await.is_some() +} + +fn unsupported_error() -> String { + format!( + "{LOOPX_UNSUPPORTED_EXECUTION_DOMAIN}: LoopX currently supports only a local Desktop workspace" + ) +} + +/// List the host-configured chat models for the LoopX model picker. This is +/// gated on the same verified-builtin check as the controller bridge (not the +/// MiniApp AI permission), because the LoopX native agent selects the model. +#[tauri::command] +pub async fn miniapp_loopx_list_models( + app_state: State<'_, AppState>, + request: MiniAppLoopxListModelsRequest, +) -> Result, String> { + authorize_builtin(&app_state, &request.app_id).await?; + let global_config = app_state + .config_service + .get_config::(None) + .await + .map_err(|error| error.to_string())?; + let primary_id = global_config + .ai + .resolve_model_selection("primary") + .unwrap_or_default(); + let fast_id = global_config + .ai + .resolve_model_selection("fast") + .unwrap_or_default(); + let models = available_models_for_permissions( + global_config + .ai + .models + .iter() + .map(|model| MiniAppAiModelDescriptor { + id: model.id.clone(), + name: model.name.clone(), + model_name: model.model_name.clone(), + provider: model.provider.clone(), + enabled: model.enabled, + supports_text_chat: model.capabilities.iter().any(|capability| { + matches!( + capability, + bitfun_core::service::config::types::ModelCapability::TextChat + ) + }), + }), + &[], + &primary_id, + &fast_id, + ); + Ok(models) +} + +#[tauri::command] +pub async fn miniapp_loopx_attach( + app_state: State<'_, AppState>, + controller: State<'_, LoopxControllerState>, + request: MiniAppLoopxAttachRequest, +) -> Result { + authorize_builtin(&app_state, &request.app_id).await?; + if is_remote_workspace(&app_state).await { + return Ok(controller + .controller + .attach( + LoopxExecutionDomain::RemoteWorkspace, + LoopxExecutionSupport::UnsupportedExecutionDomain, + Some(unsupported_error()), + ) + .await); + } + let _ = request.input; + Ok(controller + .controller + .attach( + LoopxExecutionDomain::LocalDesktop, + LoopxExecutionSupport::Supported, + None, + ) + .await) +} + +#[tauri::command] +pub async fn miniapp_loopx_resolve_intake( + app_state: State<'_, AppState>, + controller: State<'_, LoopxControllerState>, + request: MiniAppLoopxResolveIntakeRequest, +) -> Result { + authorize_builtin(&app_state, &request.app_id).await?; + if is_remote_workspace(&app_state).await { + return Err(unsupported_error()); + } + controller.controller.resolve_intake(request.input).await +} + +#[tauri::command] +pub async fn miniapp_loopx_create_task( + app_state: State<'_, AppState>, + controller: State<'_, LoopxControllerState>, + request: MiniAppLoopxCreateTaskRequest, +) -> Result { + authorize_builtin(&app_state, &request.app_id).await?; + if is_remote_workspace(&app_state).await { + return Err(unsupported_error()); + } + controller.controller.create_tasks(request.input).await +} + +#[tauri::command] +pub async fn miniapp_loopx_action( + app_state: State<'_, AppState>, + controller: State<'_, LoopxControllerState>, + request: MiniAppLoopxActionRequest, +) -> Result { + authorize_builtin(&app_state, &request.app_id).await?; + if is_remote_workspace(&app_state).await { + return Err(unsupported_error()); + } + controller.controller.action(request.input).await +} + +#[tauri::command] +pub async fn miniapp_loopx_events_since( + app_state: State<'_, AppState>, + controller: State<'_, LoopxControllerState>, + request: MiniAppLoopxEventsSinceRequest, +) -> Result { + authorize_builtin(&app_state, &request.app_id).await?; + if is_remote_workspace(&app_state).await { + return Err(unsupported_error()); + } + Ok(controller.controller.events_since(request.input).await) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unsupported_execution_domain_is_stable() { + assert!(unsupported_error().starts_with(LOOPX_UNSUPPORTED_EXECUTION_DOMAIN)); + } +} diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index f7adf9ce6f..316cad8faa 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -33,6 +33,7 @@ pub mod lsp_api; pub mod lsp_workspace_api; pub mod mcp_api; pub mod miniapp_agent_api; +pub mod miniapp_loopx_api; pub mod miniapp_api; pub mod miniapp_export_api; pub mod miniapp_market_api; diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index fcc2800946..d5f34a5d11 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -145,6 +145,14 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ // That decision stays with the person at that machine; a controller can // still read `git_get_repository_trust` and relay the manual command. "git_trust_repository", + // LoopX is local-Desktop-only until its workspace, event replay, and + // permission mailbox contracts are negotiated across Peer Device Mode. + "miniapp_loopx_attach", + "miniapp_loopx_list_models", + "miniapp_loopx_resolve_intake", + "miniapp_loopx_create_task", + "miniapp_loopx_action", + "miniapp_loopx_events_since", ]; static PENDING: OnceLock>>> = diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 54c2c63673..16a6649d7b 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -1265,6 +1265,30 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "miniapp_install_deps", RemoteWorkspacePolicy::LegacyUnaudited, ), + ( + "miniapp_loopx_action", + RemoteWorkspacePolicy::RemoteUnsupported, + ), + ( + "miniapp_loopx_attach", + RemoteWorkspacePolicy::RemoteUnsupported, + ), + ( + "miniapp_loopx_create_task", + RemoteWorkspacePolicy::RemoteUnsupported, + ), + ( + "miniapp_loopx_events_since", + RemoteWorkspacePolicy::RemoteUnsupported, + ), + ( + "miniapp_loopx_list_models", + RemoteWorkspacePolicy::RemoteUnsupported, + ), + ( + "miniapp_loopx_resolve_intake", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ( "miniapp_permission_diff_for_draft", RemoteWorkspacePolicy::LegacyUnaudited, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 96be3c3f1e..f4b5a2e8ee 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -695,6 +695,73 @@ pub async fn run() { let path_manager = get_path_manager_arc(); + let loopx_resource_dir = api::app_state::resolve_bundled_loopx_dir(); + let mut loopx_cli_config = + bitfun_services_integrations::miniapp::loopx_cli::LoopxCliAdapterConfig::packaged( + loopx_resource_dir.clone().unwrap_or_else(|| { + path_manager + .miniapp_dir(bitfun_product_domains::miniapp::loopx::LOOPX_BUILTIN_APP_ID) + .join("missing-bundled-loopx") + }), + ); + if loopx_resource_dir.is_none() { + loopx_cli_config.system_fallback = + bitfun_services_integrations::miniapp::loopx_cli::LoopxSystemFallbackPolicy::ExactPinned; + } + let loopx_cli_adapter = + bitfun_services_integrations::miniapp::loopx_cli::LoopxCliProcessAdapter::new( + loopx_cli_config, + ); + let loopx_cli_adapter = match bitfun_services_integrations::miniapp::loopx_github::GithubLoopxIntakeMetadataProvider::new() { + Ok(provider) => loopx_cli_adapter.with_intake_metadata_provider(Arc::new(provider)), + Err(error) => { + log::warn!("LoopX GitHub intake metadata is unavailable: {error}"); + loopx_cli_adapter + } + }; + let loopx_cli: Arc = + Arc::new(loopx_cli_adapter); + let loopx_workspace: Arc = + Arc::new( + bitfun_services_integrations::miniapp::loopx_workspace::LoopxWorkspaceService::new( + bitfun_services_integrations::miniapp::loopx_workspace::LoopxWorkspaceServiceConfig::new( + path_manager + .miniapp_dir(bitfun_product_domains::miniapp::loopx::LOOPX_BUILTIN_APP_ID) + .join("workspaces"), + std::path::PathBuf::from("git"), + ), + ), + ); + let loopx_agent: Arc = Arc::new( + bitfun_core::miniapp::loopx::CoreLoopxAgentPort::new(coordinator.clone()), + ); + let loopx_controller = bitfun_core::miniapp::loopx::LoopxController::load( + loopx_cli, + loopx_workspace, + loopx_agent, + bitfun_core::miniapp::loopx::LoopxStateStore::new( + path_manager + .miniapp_dir(bitfun_product_domains::miniapp::loopx::LOOPX_BUILTIN_APP_ID) + .join("loopx-controller-state.json"), + ), + ) + .await; + event_router.subscribe_internal( + "loopx_tasks".to_string(), + Arc::new(bitfun_core::miniapp::loopx::LoopxEventSubscriber::new( + loopx_controller.clone(), + )), + ); + let loopx_controller_state = api::miniapp_loopx_api::LoopxControllerState { + controller: loopx_controller.clone(), + }; + let loopx_environment_controller = loopx_controller.clone(); + tokio::spawn(async move { + if let Err(error) = loopx_environment_controller.refresh_environment().await { + log::warn!("LoopX environment initialization failed: {error}"); + } + }); + let mut builder = tauri::Builder::default(); #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] @@ -738,6 +805,7 @@ pub async fn run() { .manage(desktop_runtime) .manage(coordinator_state) .manage(scheduler_state) + .manage(loopx_controller_state) .manage(path_manager) .manage(coordinator) .manage(scheduler) @@ -766,6 +834,30 @@ pub async fn run() { .setup(move |app| { let setup_started = Instant::now(); startup_trace.record_phase("tauri_setup_start", "native_setup"); + let mut loopx_events = app + .state::() + .controller + .subscribe(); + let loopx_event_handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + loop { + match loopx_events.recv().await { + Ok(event) => { + if let Err(error) = + loopx_event_handle.emit("miniapp://loopx-event", event) + { + log::warn!("Failed to emit LoopX task event: {error}"); + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + log::warn!( + "LoopX task event subscriber lagged; clients will replay by cursor: skipped={skipped}" + ); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + }); #[cfg(target_os = "macos")] { app.on_menu_event(|app, event| { @@ -1828,6 +1920,12 @@ pub async fn run() { api::miniapp_agent_api::miniapp_agent_cancel, api::miniapp_agent_api::miniapp_agent_turn_text, api::miniapp_agent_api::miniapp_agent_cancel_stale_runs, + api::miniapp_loopx_api::miniapp_loopx_attach, + api::miniapp_loopx_api::miniapp_loopx_list_models, + api::miniapp_loopx_api::miniapp_loopx_resolve_intake, + api::miniapp_loopx_api::miniapp_loopx_create_task, + api::miniapp_loopx_api::miniapp_loopx_action, + api::miniapp_loopx_api::miniapp_loopx_events_since, api::miniapp_export_api::miniapp_render_slide_page, // Browser API (embedded webview) api::browser_api::browser_webview_eval, diff --git a/src/apps/desktop/tauri.conf.json b/src/apps/desktop/tauri.conf.json index 8ec3c4515d..87dddb1f27 100644 --- a/src/apps/desktop/tauri.conf.json +++ b/src/apps/desktop/tauri.conf.json @@ -19,7 +19,7 @@ ], "resources": { "../../mobile-web/dist": "mobile-web/dist", - "resources/worker_host.js": "resources/worker_host.js", + "resources/worker_host.cjs": "resources/worker_host.cjs", "../../../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md", "../../crates/services/services-integrations/assets/models-dev.LICENSE.txt": "third-party/models.dev/LICENSE.txt", "../../crates/services/services-integrations/assets/models-dev.provenance.json": "third-party/models.dev/provenance.json" diff --git a/src/apps/desktop/tauri.dev.conf.json b/src/apps/desktop/tauri.dev.conf.json index c7959fbf1d..6369294305 100644 --- a/src/apps/desktop/tauri.dev.conf.json +++ b/src/apps/desktop/tauri.dev.conf.json @@ -18,7 +18,7 @@ ], "resources": { "../../mobile-web/dist": "mobile-web/dist", - "resources/worker_host.js": "resources/worker_host.js", + "resources/worker_host.cjs": "resources/worker_host.cjs", "../../../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md", "../../crates/services/services-integrations/assets/models-dev.LICENSE.txt": "third-party/models.dev/LICENSE.txt", "../../crates/services/services-integrations/assets/models-dev.provenance.json": "third-party/models.dev/provenance.json" diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index a75600ad78..caec07f7fc 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -351,6 +351,7 @@ tools-miniapp = [ "bitfun-product-domains/appearance-market", "bitfun-product-domains/miniapp", "bitfun-services-integrations/miniapp-runtime", + "bitfun-services-integrations/miniapp-loopx", "bitfun-services-integrations/miniapp-market", "runtime-services", "dep:reqwest", diff --git a/src/crates/assembly/core/builtin_skills/miniapp-dev/references/examples/demo-git-graph/README.md b/src/crates/assembly/core/builtin_skills/miniapp-dev/references/examples/demo-git-graph/README.md index c2115c82d6..67b3d7b2f2 100644 --- a/src/crates/assembly/core/builtin_skills/miniapp-dev/references/examples/demo-git-graph/README.md +++ b/src/crates/assembly/core/builtin_skills/miniapp-dev/references/examples/demo-git-graph/README.md @@ -28,7 +28,7 @@ This demo showcases BitFun MiniApp's full-stack collaboration capability — spe 1. **UI → Bridge**: `app.call('git.log', { cwd, maxCount })` etc. via `window.app` (JSON-RPC) 2. **Bridge → Tauri**: postMessage intercepted by the host `useMiniAppBridge`, which calls `miniapp_worker_call` 3. **Tauri → Worker**: Rust writes the request to Worker stdin (JSON-RPC) -4. **Worker**: `worker_host.js` loads `source/worker.js`; exported handlers are invoked — primarily `git.graphData` (returns commits + refs + stashes + uncommitted in one response), plus `git.show`, `git.checkout`, `git.merge`, `git.push`, `git.stashPush`, and 20+ other methods — all backed by the `simple-git` npm package +4. **Worker**: `worker_host.cjs` loads `source/worker.js`; exported handlers are invoked — primarily `git.graphData` (returns commits + refs + stashes + uncommitted in one response), plus `git.show`, `git.checkout`, `git.merge`, `git.push`, `git.stashPush`, and 20+ other methods — all backed by the `simple-git` npm package 5. **Worker → Tauri → Bridge → UI**: response travels back via stderr → Rust → postMessage to iframe → UI refreshes graph and detail panel ### Directory Structure @@ -117,7 +117,7 @@ miniapps/git-graph/ 1. **UI → Bridge**:`app.call('git.log', { cwd, maxCount })` 等通过 `window.app` 发起 RPC 2. **Bridge → Tauri**:postMessage 被宿主 `useMiniAppBridge` 接收,调用 `miniapp_worker_call` 3. **Tauri → Worker**:Rust 将请求写入 Worker 进程 stdin(JSON-RPC) -4. **Worker**:`worker_host.js` 加载本目录 `source/worker.js`,其导出的处理函数被调用 — 主要是 `git.graphData`(一次返回提交 + refs + stash + 未提交变更),以及 `git.show`、`git.checkout`、`git.merge`、`git.push`、`git.stashPush` 等 20+ 个方法 — 均基于 `simple-git` npm 包 +4. **Worker**:`worker_host.cjs` 加载本目录 `source/worker.js`,其导出的处理函数被调用 — 主要是 `git.graphData`(一次返回提交 + refs + stash + 未提交变更),以及 `git.show`、`git.checkout`、`git.merge`、`git.push`、`git.stashPush` 等 20+ 个方法 — 均基于 `simple-git` npm 包 5. **Worker → Tauri → Bridge → UI**:响应经 stderr 回传 Rust,再 postMessage 回 iframe,UI 更新图谱与详情 ### 目录结构 diff --git a/src/crates/assembly/core/src/miniapp/js_worker_pool.rs b/src/crates/assembly/core/src/miniapp/js_worker_pool.rs index c41e0b9abc..8c9da26241 100644 --- a/src/crates/assembly/core/src/miniapp/js_worker_pool.rs +++ b/src/crates/assembly/core/src/miniapp/js_worker_pool.rs @@ -29,11 +29,13 @@ impl JsWorkerPool { pub fn new( path_manager: Arc, worker_host_path: PathBuf, + resource_dir: Option, ) -> BitFunResult { let event_sink: SharedMiniAppWorkerEventSink = Arc::new(CoreMiniAppWorkerEventSink); ServiceJsWorkerPool::new( path_manager.miniapps_dir(), worker_host_path, + resource_dir, Some(event_sink), ) .map(|inner| Self { inner }) @@ -179,6 +181,7 @@ impl JsWorkerPool { inner: ServiceJsWorkerPool::from_runtime( path_manager.miniapps_dir(), worker_host_path, + None, runtime, Some(Arc::new(CoreMiniAppWorkerEventSink)), ), diff --git a/src/crates/assembly/core/src/miniapp/loopx/agent_adapter.rs b/src/crates/assembly/core/src/miniapp/loopx/agent_adapter.rs new file mode 100644 index 0000000000..a237445efa --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/agent_adapter.rs @@ -0,0 +1,177 @@ +use crate::agentic::coordination::ConversationCoordinator; +use bitfun_product_domains::miniapp::loopx::{ + LoopxAgentCancelRequest, LoopxAgentCancelResult, LoopxAgentFinishRequest, + LoopxAgentFinishResult, LoopxAgentPort, LoopxAgentStartRequest, LoopxAgentStartResult, + LoopxHostFuture, LoopxHostPortError, LoopxHostPortErrorKind, +}; +use bitfun_runtime_ports::{ + AgentSessionCreateRequest, AgentSubmissionPort, AgentSubmissionRequest, + AgentSubmissionSource, AgentTurnCancellationPort, AgentTurnCancellationRequest, +}; +use std::path::Path; +use std::sync::Arc; + +pub struct CoreLoopxAgentPort { + coordinator: Arc, +} + +impl CoreLoopxAgentPort { + pub fn new(coordinator: Arc) -> Self { + Self { coordinator } + } +} + +impl LoopxAgentPort for CoreLoopxAgentPort { + fn start(&self, request: LoopxAgentStartRequest) -> LoopxHostFuture<'_, LoopxAgentStartResult> { + Box::pin(async move { + if request.worktree_path.trim().is_empty() { + return Err(host_error( + LoopxHostPortErrorKind::InvalidInput, + "LoopX Agent worktree path is required", + &request.operation_id, + )); + } + let session_id = format!("loopx-{}", uuid::Uuid::new_v4()); + let turn_id = format!("loopx-turn-{}", uuid::Uuid::new_v4()); + let mut metadata = serde_json::Map::new(); + metadata.insert("surface".to_string(), serde_json::json!("loopx")); + metadata.insert("loopxTaskId".to_string(), serde_json::json!(request.task_id)); + metadata.insert("generation".to_string(), serde_json::json!(request.generation)); + metadata.insert("goalId".to_string(), serde_json::json!(request.metadata.goal_id)); + metadata.insert( + "loopxTurnId".to_string(), + serde_json::json!(request.metadata.loopx_turn_id), + ); + let created = AgentSubmissionPort::create_transient_session_with_id( + self.coordinator.as_ref(), + session_id.clone(), + AgentSessionCreateRequest { + session_name: format!("LoopX #{}", request.metadata.item.number), + agent_type: "Cowork".to_string(), + workspace_path: Some(request.worktree_path), + project_workspace_path: None, + execution_target: None, + workspace_id: None, + remote_connection_id: None, + remote_ssh_host: None, + model_id: (!request.model_id.trim().is_empty() && request.model_id != "auto") + .then_some(request.model_id), + metadata: metadata.clone(), + }, + ) + .await + .map_err(|error| map_port_error(error, &request.operation_id))?; + let submitted = AgentSubmissionPort::submit_message( + self.coordinator.as_ref(), + AgentSubmissionRequest { + session_id: created.session_id.clone(), + message: request.prompt, + turn_id: Some(turn_id.clone()), + source: Some(AgentSubmissionSource::DesktopApi), + attachments: Vec::new(), + metadata, + }, + ) + .await + .map_err(|error| map_port_error(error, &request.operation_id))?; + if !submitted.accepted { + let _ = self + .coordinator + .discard_transient_session( + Path::new(&created.workspace_path.unwrap_or_default()), + None, + None, + &created.session_id, + ) + .await; + return Err(host_error( + LoopxHostPortErrorKind::Conflict, + "LoopX Agent turn was not accepted", + &request.operation_id, + )); + } + Ok(LoopxAgentStartResult { + session_id: created.session_id, + turn_id: submitted.turn_id, + }) + }) + } + + fn cancel( + &self, + request: LoopxAgentCancelRequest, + ) -> LoopxHostFuture<'_, LoopxAgentCancelResult> { + Box::pin(async move { + let result = AgentTurnCancellationPort::cancel_turn( + self.coordinator.as_ref(), + AgentTurnCancellationRequest { + session_id: request.session_id, + turn_id: Some(request.turn_id), + source: Some(AgentSubmissionSource::DesktopApi), + requester_session_id: None, + reason: Some("LoopX task paused by the user".to_string()), + wait_timeout_ms: Some(5_000), + cancel_descendants: true, + }, + ) + .await + .map_err(|error| map_port_error(error, &request.operation_id))?; + Ok(LoopxAgentCancelResult { + target_operation_id: request.target_operation_id, + cancelled: result.requested, + }) + }) + } + + fn finish( + &self, + request: LoopxAgentFinishRequest, + ) -> LoopxHostFuture<'_, LoopxAgentFinishResult> { + Box::pin(async move { + let discarded = self + .coordinator + .discard_transient_session( + Path::new(&request.worktree_path), + None, + None, + &request.session_id, + ) + .await + .map_err(|error| { + host_error( + LoopxHostPortErrorKind::Backend, + error.to_string(), + &request.operation_id, + ) + })?; + Ok(LoopxAgentFinishResult { + session_id: request.session_id, + discarded, + }) + }) + } +} + +fn map_port_error( + error: bitfun_runtime_ports::PortError, + operation_id: &str, +) -> LoopxHostPortError { + host_error( + LoopxHostPortErrorKind::Backend, + error.to_string(), + operation_id, + ) +} + +fn host_error( + kind: LoopxHostPortErrorKind, + message: impl Into, + operation_id: &str, +) -> LoopxHostPortError { + LoopxHostPortError { + kind, + message: message.into(), + operation_id: Some(operation_id.to_string()), + retryable: false, + } +} diff --git a/src/crates/assembly/core/src/miniapp/loopx/controller.rs b/src/crates/assembly/core/src/miniapp/loopx/controller.rs new file mode 100644 index 0000000000..9796e64e66 --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/controller.rs @@ -0,0 +1,1555 @@ +use super::{LoopxPersistedState, LoopxStateStore, LoopxTaskRuntimeRecord}; +use bitfun_product_domains::miniapp::loopx::*; +use std::collections::HashMap; +use std::sync::{Arc, Mutex as StdMutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tokio::sync::{broadcast, mpsc, Mutex, RwLock}; + +const DEFAULT_AGENT_ID: &str = "bitfun-agent"; +const EVENT_CHANNEL_CAPACITY: usize = 256; + +struct ScheduledTask { + task_id: String, + delay: Duration, +} + +#[derive(Default)] +struct BufferedProgress(StdMutex>); + +impl BufferedProgress { + fn take(&self) -> Vec { + std::mem::take( + &mut *self + .0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()), + ) + } +} + +impl LoopxCliProgressSink for BufferedProgress { + fn report(&self, progress: LoopxCliProgress) { + self.0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(progress); + } +} + +pub struct LoopxController { + cli: Arc, + workspace: Arc, + agent: Arc, + store: LoopxStateStore, + state: RwLock, + mutation_lock: Mutex<()>, + previews: RwLock>, + active_repositories: Mutex>, + event_sender: broadcast::Sender, + task_sender: mpsc::UnboundedSender, + load_error: RwLock>, +} + +impl LoopxController { + pub async fn load( + cli: Arc, + workspace: Arc, + agent: Arc, + store: LoopxStateStore, + ) -> Arc { + let now = now_ms(); + let (mut persisted, load_error) = match store.load().await { + Ok(Some(state)) => (state, None), + Ok(None) => (LoopxPersistedState::new(now), None), + Err(error) => (LoopxPersistedState::new(now), Some(error)), + }; + let restart_changed = load_error.is_none() && persisted.apply_restart_policy(now); + let (event_sender, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY); + let (task_sender, mut task_receiver) = mpsc::unbounded_channel::(); + let controller = Arc::new(Self { + cli, + workspace, + agent, + store, + state: RwLock::new(persisted), + mutation_lock: Mutex::new(()), + previews: RwLock::new(HashMap::new()), + active_repositories: Mutex::new(HashMap::new()), + event_sender, + task_sender, + load_error: RwLock::new(load_error), + }); + if restart_changed { + if let Err(error) = controller.persist_current().await { + *controller.load_error.write().await = Some(error); + } + } + let task_runner = Arc::clone(&controller); + tokio::spawn(async move { + while let Some(scheduled) = task_receiver.recv().await { + if !scheduled.delay.is_zero() { + tokio::time::sleep(scheduled.delay).await; + } + if let Err(error) = task_runner.drive_task(scheduled.task_id.clone()).await { + let _ = task_runner.fail_task(&scheduled.task_id, error).await; + } + } + }); + controller + } + + pub fn subscribe(&self) -> broadcast::Receiver { + self.event_sender.subscribe() + } + + pub async fn attach( + &self, + execution_domain: LoopxExecutionDomain, + execution_support: LoopxExecutionSupport, + unsupported_reason: Option, + ) -> LoopxAttachResponse { + let state = self.state.read().await; + let mut snapshot = state.snapshot( + execution_domain, + execution_support, + unsupported_reason, + now_ms(), + ); + if let Some(error) = self.load_error.read().await.clone() { + snapshot.execution_support = LoopxExecutionSupport::UnsupportedExecutionDomain; + snapshot.unsupported_reason = Some(error); + snapshot.environment.status = LoopxEnvironmentStatus::Blocked; + } + LoopxAttachResponse { snapshot } + } + + pub async fn events_since(&self, request: LoopxEventsSinceRequest) -> LoopxEventsSinceResponse { + self.state.read().await.events_since( + &request.stream_id, + request.after_cursor, + request.limit, + ) + } + + pub async fn refresh_environment(self: &Arc) -> Result<(), String> { + self.ensure_writable().await?; + let operation_id = format!("environment-{}", uuid::Uuid::new_v4()); + self.update_environment(LoopxEnvironmentStatus::Checking, None, None) + .await?; + let progress = BufferedProgress::default(); + let handshake = self + .cli + .handshake( + LoopxCliHandshakeRequest { + call: LoopxCliCallContext { + operation_id, + deadline_at: None, + }, + ..LoopxCliHandshakeRequest::default() + }, + &progress, + ) + .await; + self.record_progress(progress.take()).await?; + match handshake { + Ok(manifest) => { + let github_auth = self.probe_github_auth().await; + self.update_environment( + LoopxEnvironmentStatus::Ready, + Some(manifest), + Some(github_auth), + ) + .await + } + Err(error) => { + self.update_environment_error(error.to_string()).await?; + Err(error.to_string()) + } + } + } + + async fn probe_github_auth(&self) -> LoopxGithubAuthProbe { + let operation_id = format!("github-auth-{}", uuid::Uuid::new_v4()); + match self + .cli + .probe_github_auth(LoopxGithubAuthProbeRequest { + call: LoopxCliCallContext { + operation_id, + deadline_at: None, + }, + }) + .await + { + Ok(probe) => probe, + Err(error) => LoopxGithubAuthProbe { + authenticated: false, + detail: Some(format!("GitHub auth probe failed: {error}")), + ..LoopxGithubAuthProbe::default() + }, + } + } + + pub async fn resolve_intake( + &self, + request: LoopxResolveIntakeRequest, + ) -> Result { + self.ensure_writable().await?; + let target = parse_loopx_intake(&request.input).map_err(|error| error.to_string())?; + let operation_id = format!("resolve-{}", uuid::Uuid::new_v4()); + let progress = BufferedProgress::default(); + let resolved = self + .cli + .resolve_intake( + LoopxCliResolveIntakeRequest { + call: LoopxCliCallContext { + operation_id, + deadline_at: None, + }, + input: request.input, + target: target.clone(), + }, + &progress, + ) + .await + .map_err(|error| error.to_string())?; + self.record_progress(progress.take()).await?; + let scopes = vec![ + LoopxPermissionScope::WorkspaceRead, + LoopxPermissionScope::WorkspaceWrite, + LoopxPermissionScope::GitLocal, + LoopxPermissionScope::GithubRead, + LoopxPermissionScope::AgentExecution, + ]; + let model = LoopxModelCapability { + model_id: request.model_id, + available: true, + supports_images: false, + }; + let workspace = LoopxWorkspacePreview { + disposition: LoopxWorkspaceDisposition::CloneRequired, + path: None, + repository_verified: false, + }; + let fingerprint = build_intake_fingerprint( + &resolved.target, + &resolved.candidates, + None, + &model.model_id, + &scopes, + ); + let preview = LoopxIntakePreview { + fingerprint: fingerprint.clone(), + target: resolved.target, + repository: resolved.repository, + workspace, + candidates: resolved.candidates, + truncated: resolved.truncated, + model, + permission_scopes: scopes, + resolved_at: resolved.resolved_at, + expires_at: None, + }; + self.previews + .write() + .await + .insert(fingerprint, preview.clone()); + Ok(LoopxResolveIntakeResponse { preview }) + } + + pub async fn create_tasks( + self: &Arc, + request: LoopxCreateTaskRequest, + ) -> Result { + self.ensure_writable().await?; + if request.client_request_id.trim().is_empty() { + return Err("clientRequestId is required".to_string()); + } + let preview = self + .previews + .read() + .await + .get(&request.preview_fingerprint) + .cloned() + .ok_or_else(|| "Intake preview is missing or stale; resolve it again".to_string())?; + let selected = request + .selected_items + .iter() + .cloned() + .collect::>(); + if selected.is_empty() { + return Err("Select at least one issue or pull request".to_string()); + } + if selected.iter().any(|key| { + !preview + .candidates + .iter() + .any(|candidate| &candidate.key == key) + }) { + return Err("Selected item was not present in the intake preview".to_string()); + } + if request.granted_scopes.iter().any(|scope| { + !preview.permission_scopes.contains(scope) || !intake_scope_is_pregrantable(*scope) + }) { + return Err("Intake includes a permission scope that was not previewed".to_string()); + } + + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let start_cursor = state.cursor; + if state.has_processed_request(&request.client_request_id) { + return Ok(LoopxCreateTaskResponse { + outcomes: existing_outcomes(&state, &selected), + snapshot_revision: state.revision, + }); + } + let existing = state + .tasks + .iter() + .map(|task| LoopxExistingTask { + task_id: task.task_id.clone(), + identity: task.identity.clone(), + state: task.state, + }) + .collect::>(); + let batch_id = (selected.len() > 1).then(|| uuid::Uuid::new_v4().to_string()); + let now = now_ms(); + let mut outcomes = Vec::new(); + let mut created_task_ids = Vec::new(); + for key in selected { + let candidate = preview + .candidates + .iter() + .find(|candidate| candidate.key == key) + .expect("selected candidates were validated before mutation"); + match decide_task_dedup(&key, candidate.state, &existing, request.retry_terminal) { + LoopxDedupDecision::OpenExisting { task_id } => { + outcomes.push(LoopxCreateTaskOutcome { + item: key, + kind: LoopxCreateTaskOutcomeKind::OpenedExisting, + task_id: Some(task_id), + ..LoopxCreateTaskOutcome::default() + }) + } + LoopxDedupDecision::RequireExplicitRetry { + previous_task_id, + next_attempt, + } => outcomes.push(LoopxCreateTaskOutcome { + item: key, + kind: LoopxCreateTaskOutcomeKind::RetryConfirmationRequired, + task_id: Some(previous_task_id), + attempt: Some(next_attempt), + ..LoopxCreateTaskOutcome::default() + }), + LoopxDedupDecision::ClosedNoop => outcomes.push(LoopxCreateTaskOutcome { + item: key, + kind: LoopxCreateTaskOutcomeKind::ClosedNoop, + ..LoopxCreateTaskOutcome::default() + }), + LoopxDedupDecision::NeedsLiveVerification => { + outcomes.push(LoopxCreateTaskOutcome { + item: key, + kind: LoopxCreateTaskOutcomeKind::NeedsLiveVerification, + ..LoopxCreateTaskOutcome::default() + }) + } + LoopxDedupDecision::CreateAttempt { attempt } => { + let task_id = uuid::Uuid::new_v4().to_string(); + let operation_id = format!("prepare-{task_id}-1"); + let task = LoopxTaskSnapshot { + task_id: task_id.clone(), + batch_id: batch_id.clone(), + identity: LoopxTaskIdentity { + item: key.clone(), + attempt, + }, + generation: 1, + revision: 1, + agent_id: Some(DEFAULT_AGENT_ID.to_string()), + state: LoopxTaskState::Preparing, + phase: LoopxPhase::PreparingWorkspace, + model_id: Some(request.model_id.clone()), + granted_scopes: request.granted_scopes.clone(), + created_at: now, + updated_at: now, + ..LoopxTaskSnapshot::default() + }; + state.runtime.insert( + task_id.clone(), + LoopxTaskRuntimeRecord { + operation_id, + ..LoopxTaskRuntimeRecord::default() + }, + ); + state.tasks.push(task); + state.revision = state.revision.saturating_add(1); + state.append_event(LoopxEvent { + task_id: Some(task_id.clone()), + generation: Some(1), + revision: Some(1), + kind: LoopxEventKind::TaskCreated, + source: LoopxEventSource::Controller, + phase: Some(LoopxPhase::PreparingWorkspace), + message: "LoopX task reserved before workspace preparation".to_string(), + important: true, + occurred_at: now, + ..LoopxEvent::default() + }); + outcomes.push(LoopxCreateTaskOutcome { + item: key, + kind: LoopxCreateTaskOutcomeKind::Created, + task_id: Some(task_id.clone()), + attempt: Some(attempt), + ..LoopxCreateTaskOutcome::default() + }); + created_task_ids.push(task_id); + } + } + } + state.record_processed_request(request.client_request_id); + let snapshot_revision = state.revision; + let persisted = state.clone(); + drop(state); + self.store.save(&persisted).await?; + drop(_mutation); + self.broadcast_new_events(&persisted, start_cursor); + + for task_id in created_task_ids { + self.enqueue_task(task_id, Duration::ZERO)?; + } + Ok(LoopxCreateTaskResponse { + outcomes, + snapshot_revision, + }) + } + + pub async fn action( + self: &Arc, + request: LoopxActionRequest, + ) -> Result { + self.ensure_writable().await?; + if request.action == LoopxActionKind::RetryEnvironment { + self.refresh_environment().await?; + return Ok(LoopxActionResponse { + current_revision: self.state.read().await.revision, + ..LoopxActionResponse::default() + }); + } + let task_id = request + .task_id + .clone() + .ok_or_else(|| "taskId is required".to_string())?; + let (task, runtime) = { + let state = self.state.read().await; + if state.has_processed_request(&request.client_request_id) { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Duplicate, + current_revision: state.revision, + task: state + .tasks + .iter() + .find(|task| task.task_id == task_id) + .cloned(), + ..LoopxActionResponse::default() + }); + } + let task = state + .tasks + .iter() + .find(|task| task.task_id == task_id) + .cloned() + .ok_or_else(|| "LoopX task not found".to_string())?; + if task.revision != request.expected_revision { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::RevisionConflict, + current_revision: task.revision, + task: Some(task), + message: Some("Task changed; refresh before applying the action".to_string()), + }); + } + ( + task, + state.runtime.get(&task_id).cloned().unwrap_or_default(), + ) + }; + + match request.action { + LoopxActionKind::Pause => { + self.pause_task(&task, &runtime, &request.client_request_id) + .await + } + LoopxActionKind::Resume => self.resume_task(&task, &request.client_request_id).await, + LoopxActionKind::Approve | LoopxActionKind::Reject => { + self.answer_gate(&task, &runtime, &request).await + } + LoopxActionKind::Archive => { + self.transition_action( + &task_id, + LoopxTaskState::Archived, + LoopxPhase::Finished, + &request.client_request_id, + ) + .await + } + LoopxActionKind::Restore => { + self.transition_action( + &task_id, + LoopxTaskState::RecoveryRequired, + LoopxPhase::Recovering, + &request.client_request_id, + ) + .await + } + LoopxActionKind::RetryEnvironment => unreachable!(), + } + } + + pub async fn handle_agent_terminal( + self: &Arc, + turn_id: &str, + status: LoopxAgentTurnStatus, + summary: Option, + ) -> Result<(), String> { + let (task, runtime) = { + let state = self.state.read().await; + let Some((task_id, runtime)) = state + .runtime + .iter() + .find(|(_, runtime)| runtime.agent_turn_id.as_deref() == Some(turn_id)) + else { + return Ok(()); + }; + let Some(task) = state.tasks.iter().find(|task| &task.task_id == task_id) else { + return Ok(()); + }; + (task.clone(), runtime.clone()) + }; + if task.state != LoopxTaskState::Running { + return Ok(()); + } + self.update_task_phase( + &task.task_id, + task.generation, + LoopxPhase::ValidatingProgress, + "Agent turn ended; validating durable LoopX progress", + ) + .await?; + let progress = BufferedProgress::default(); + let result = self + .cli + .settle_turn( + LoopxCliSettleTurnRequest { + context: goal_context(&task, &runtime), + goal_id: task.goal_id.clone().unwrap_or_default(), + agent_id: task + .agent_id + .clone() + .unwrap_or_else(|| DEFAULT_AGENT_ID.to_string()), + turn_id: runtime.loopx_turn_id.clone().unwrap_or_default(), + settlement_token: runtime.settlement_token.clone().unwrap_or_default(), + expected_durable_revision: runtime + .expected_durable_revision + .clone() + .unwrap_or_default(), + agent_status: status, + agent_summary: summary, + }, + &progress, + ) + .await; + self.record_progress(progress.take()).await?; + let finish_result = if let (Some(session_id), Some(agent_turn_id)) = + (runtime.session_id.clone(), runtime.agent_turn_id.clone()) + { + self.agent + .finish(LoopxAgentFinishRequest { + operation_id: format!("finish-agent-{}", uuid::Uuid::new_v4()), + task_id: task.task_id.clone(), + generation: task.generation, + worktree_path: task.workspace_path.clone().unwrap_or_default(), + session_id, + turn_id: agent_turn_id, + }) + .await + .map_err(|error| error.to_string()) + } else { + Ok(LoopxAgentFinishResult::default()) + }; + match (result, finish_result) { + (Ok(settlement), Ok(_)) => self.apply_settlement(&task, settlement).await, + (Err(error), _) => self.fail_task(&task.task_id, error.to_string()).await, + (Ok(_), Err(error)) => self.fail_task(&task.task_id, error).await, + } + } + + pub async fn handle_agent_activity( + &self, + turn_id: &str, + tool_name: Option, + ) -> Result<(), String> { + let task_id = { + let state = self.state.read().await; + state + .runtime + .iter() + .find(|(_, runtime)| runtime.agent_turn_id.as_deref() == Some(turn_id)) + .map(|(task_id, _)| task_id.clone()) + }; + let Some(task_id) = task_id else { + return Ok(()); + }; + let updated = self + .mutate_task(&task_id, None, |task, _| { + if task.state != LoopxTaskState::Running { + return; + } + task.last_output_at = Some(now_ms()); + task.current_tool = tool_name.clone(); + task.revision = task.revision.saturating_add(1); + }) + .await?; + if let Some(tool_name) = tool_name { + self.append_task_event( + &updated, + LoopxEventKind::Log, + &format!("Agent tool activity: {tool_name}"), + false, + ) + .await?; + } + Ok(()) + } + + async fn drive_task(self: &Arc, task_id: String) -> Result<(), String> { + let task = self.task(&task_id).await?; + if !self.reserve_repository(&task).await { + self.transition_task( + &task_id, + task.generation, + LoopxTaskState::Queued, + LoopxPhase::Queued, + "Another task for this repository is running", + ) + .await?; + return Ok(()); + } + let workspace = self + .workspace + .prepare(LoopxWorkspacePrepareRequest { + operation_id: format!("workspace-{}-{}", task.task_id, task.generation), + task_id: task.task_id.clone(), + item: task.identity.item.clone(), + }) + .await + .map_err(|error| error.to_string())?; + if !workspace.repository_verified { + return Err("Prepared worktree does not match the requested repository".to_string()); + } + self.bind_workspace(&task_id, task.generation, &workspace) + .await?; + let task = self.task(&task_id).await?; + let runtime = self.runtime(&task_id).await; + let progress = BufferedProgress::default(); + let intake = self + .cli + .plan_item( + LoopxCliPlanItemRequest { + context: goal_context(&task, &runtime), + item: task.identity.item.clone(), + }, + &progress, + ) + .await + .map_err(|error| error.to_string())?; + self.record_progress(progress.take()).await?; + let goal_id = goal_id_for(&task.identity); + let progress = BufferedProgress::default(); + let created = self + .cli + .create_goal( + LoopxCliCreateGoalRequest { + context: goal_context(&task, &runtime), + goal_id: goal_id.clone(), + agent_id: task + .agent_id + .clone() + .unwrap_or_else(|| DEFAULT_AGENT_ID.to_string()), + intake, + granted_scopes: task.granted_scopes.clone(), + }, + &progress, + ) + .await + .map_err(|error| error.to_string())?; + self.record_progress(progress.take()).await?; + self.bind_goal(&task_id, task.generation, created).await?; + self.drive_turn(task_id).await + } + + async fn drive_turn(self: &Arc, task_id: String) -> Result<(), String> { + let task = self.task(&task_id).await?; + let runtime = self.runtime(&task_id).await; + let progress = BufferedProgress::default(); + let inspected = self + .cli + .inspect_goal( + LoopxCliInspectGoalRequest { + context: goal_context(&task, &runtime), + goal_id: task.goal_id.clone().unwrap_or_default(), + agent_id: task + .agent_id + .clone() + .unwrap_or_else(|| DEFAULT_AGENT_ID.to_string()), + }, + &progress, + ) + .await + .map_err(|error| error.to_string())?; + self.record_progress(progress.take()).await?; + match inspected.run_decision { + LoopxCliRunDecision::Wait => { + self.release_repository(&task).await; + self.transition_task( + &task_id, + task.generation, + LoopxTaskState::Queued, + LoopxPhase::Queued, + "LoopX is waiting before the next bounded turn", + ) + .await?; + self.schedule_next_for_repository( + &task.identity.item.repository.canonical_id(), + Some(&task_id), + ) + .await; + if let Some(delay) = inspected.scheduler_hint_ms { + self.enqueue_task(task_id, Duration::from_millis(delay))?; + } + Ok(()) + } + LoopxCliRunDecision::WaitingForUser => { + self.release_repository(&task).await; + self.transition_task( + &task_id, + task.generation, + LoopxTaskState::WaitingForUser, + LoopxPhase::WaitingForApproval, + "LoopX requires an explicit user decision", + ) + .await?; + self.schedule_next_for_repository( + &task.identity.item.repository.canonical_id(), + Some(&task_id), + ) + .await; + Ok(()) + } + LoopxCliRunDecision::Complete => { + self.release_repository(&task).await; + self.transition_task( + &task_id, + task.generation, + LoopxTaskState::Completed, + LoopxPhase::Finished, + "LoopX goal completed", + ) + .await?; + self.schedule_next_for_repository( + &task.identity.item.repository.canonical_id(), + Some(&task_id), + ) + .await; + Ok(()) + } + LoopxCliRunDecision::Failed => { + self.release_repository(&task).await; + self.fail_task(&task_id, "LoopX reported a failed goal".to_string()) + .await + } + LoopxCliRunDecision::RunNow => { + let progress = BufferedProgress::default(); + let turn = self + .cli + .build_turn( + LoopxCliBuildTurnRequest { + context: goal_context(&task, &runtime), + goal_id: task.goal_id.clone().unwrap_or_default(), + agent_id: task + .agent_id + .clone() + .unwrap_or_else(|| DEFAULT_AGENT_ID.to_string()), + expected_durable_revision: inspected.durable_revision, + }, + &progress, + ) + .await + .map_err(|error| error.to_string())?; + self.record_progress(progress.take()).await?; + self.bind_turn(&task, &turn).await?; + let started = self + .agent + .start(LoopxAgentStartRequest { + operation_id: format!("agent-{}-{}", task.task_id, task.generation), + task_id: task.task_id.clone(), + generation: task.generation, + worktree_path: task.workspace_path.clone().unwrap_or_default(), + prompt: turn.prompt, + model_id: task.model_id.clone().unwrap_or_else(|| "auto".to_string()), + metadata: LoopxAgentTurnMetadata { + goal_id: task.goal_id.clone().unwrap_or_default(), + loopx_turn_id: turn.turn_id, + item: task.identity.item.clone(), + attempt: task.identity.attempt, + }, + }) + .await + .map_err(|error| error.to_string())?; + self.bind_agent_run(&task, started).await + } + } + } + + async fn pause_task( + self: &Arc, + task: &LoopxTaskSnapshot, + runtime: &LoopxTaskRuntimeRecord, + request_id: &str, + ) -> Result { + self.transition_task( + &task.task_id, + task.generation, + LoopxTaskState::Cancelling, + LoopxPhase::Cancelling, + "Cancelling the active LoopX task", + ) + .await?; + if let (Some(session_id), Some(turn_id)) = (&runtime.session_id, &runtime.agent_turn_id) { + self.agent + .cancel(LoopxAgentCancelRequest { + operation_id: format!("cancel-agent-{}", uuid::Uuid::new_v4()), + target_operation_id: runtime.operation_id.clone(), + task_id: task.task_id.clone(), + generation: task.generation, + session_id: session_id.clone(), + turn_id: turn_id.clone(), + }) + .await + .map_err(|error| error.to_string())?; + self.agent + .finish(LoopxAgentFinishRequest { + operation_id: format!("finish-paused-agent-{}", uuid::Uuid::new_v4()), + task_id: task.task_id.clone(), + generation: task.generation, + worktree_path: task.workspace_path.clone().unwrap_or_default(), + session_id: session_id.clone(), + turn_id: turn_id.clone(), + }) + .await + .map_err(|error| error.to_string())?; + } + let progress = BufferedProgress::default(); + let _ = self + .cli + .cancel( + LoopxCliCancelRequest { + call: LoopxCliCallContext { + operation_id: format!("cancel-cli-{}", uuid::Uuid::new_v4()), + deadline_at: None, + }, + target_operation_id: runtime.operation_id.clone(), + }, + &progress, + ) + .await; + self.record_progress(progress.take()).await?; + self.release_repository(task).await; + let response = self + .transition_action( + &task.task_id, + LoopxTaskState::Stopped, + LoopxPhase::Finished, + request_id, + ) + .await?; + self.schedule_next_for_repository( + &task.identity.item.repository.canonical_id(), + Some(&task.task_id), + ) + .await; + Ok(response) + } + + async fn resume_task( + self: &Arc, + task: &LoopxTaskSnapshot, + request_id: &str, + ) -> Result { + if !matches!( + task.state, + LoopxTaskState::Stopped | LoopxTaskState::Failed | LoopxTaskState::RecoveryRequired + ) { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Rejected, + current_revision: task.revision, + task: Some(task.clone()), + message: Some( + "Only stopped, failed, or recovery-required tasks can resume".to_string(), + ), + }); + } + let updated = self + .mutate_task(&task.task_id, Some(request_id), |task, runtime| { + task.generation = task.generation.saturating_add(1); + task.revision = task.revision.saturating_add(1); + task.state = LoopxTaskState::Queued; + task.phase = LoopxPhase::Recovering; + task.current_turn_id = None; + task.error = None; + runtime.operation_id = format!("resume-{}-{}", task.task_id, task.generation); + runtime.session_id = None; + runtime.agent_turn_id = None; + runtime.loopx_turn_id = None; + runtime.settlement_token = None; + runtime.expected_durable_revision = None; + }) + .await?; + let task_id = task.task_id.clone(); + self.enqueue_task(task_id, Duration::ZERO)?; + Ok(LoopxActionResponse { + current_revision: updated.revision, + task: Some(updated), + ..LoopxActionResponse::default() + }) + } + + async fn answer_gate( + self: &Arc, + task: &LoopxTaskSnapshot, + runtime: &LoopxTaskRuntimeRecord, + request: &LoopxActionRequest, + ) -> Result { + let gate_id = request + .gate_id + .clone() + .ok_or_else(|| "gateId is required".to_string())?; + let progress = BufferedProgress::default(); + let result = self + .cli + .answer_gate( + LoopxCliAnswerGateRequest { + context: goal_context(task, runtime), + goal_id: task.goal_id.clone().unwrap_or_default(), + agent_id: task + .agent_id + .clone() + .unwrap_or_else(|| DEFAULT_AGENT_ID.to_string()), + gate_id, + decision: if request.action == LoopxActionKind::Approve { + LoopxCliGateDecision::Approve + } else { + LoopxCliGateDecision::Reject + }, + note: request.note.clone(), + granted_scope: None, + }, + &progress, + ) + .await + .map_err(|error| error.to_string())?; + self.record_progress(progress.take()).await?; + if !result.applied { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Rejected, + current_revision: task.revision, + task: Some(task.clone()), + message: Some("LoopX did not apply the gate decision".to_string()), + }); + } + let response = self + .transition_action( + &task.task_id, + LoopxTaskState::Queued, + LoopxPhase::Queued, + &request.client_request_id, + ) + .await?; + if request.action == LoopxActionKind::Approve { + let task_id = task.task_id.clone(); + self.enqueue_task(task_id, Duration::ZERO)?; + } else { + self.release_repository(task).await; + self.schedule_next_for_repository( + &task.identity.item.repository.canonical_id(), + Some(&task.task_id), + ) + .await; + } + Ok(response) + } + + async fn apply_settlement( + self: &Arc, + task: &LoopxTaskSnapshot, + settlement: LoopxCliSettleTurnResult, + ) -> Result<(), String> { + let final_state = match settlement.status { + LoopxCliSettlementStatus::GoalCompleted => LoopxTaskState::Completed, + LoopxCliSettlementStatus::Settled | LoopxCliSettlementStatus::AlreadySettled => { + LoopxTaskState::Queued + } + LoopxCliSettlementStatus::NoDurableProgress + | LoopxCliSettlementStatus::RetryRequired => LoopxTaskState::RecoveryRequired, + }; + let phase = if final_state == LoopxTaskState::Completed { + LoopxPhase::Finished + } else { + LoopxPhase::Queued + }; + let updated = self + .mutate_task(&task.task_id, None, |task, runtime| { + task.state = final_state; + task.phase = phase; + task.revision = task.revision.saturating_add(1); + task.current_turn_id = None; + task.deadline_at = None; + task.settlement = LoopxSettlementSummary { + turn_id: Some(settlement.turn_id.clone()), + receipt_id: Some(settlement.receipt_id.clone()), + durable_revision: Some(settlement.after_revision.clone()), + settled_at: Some(now_ms()), + }; + runtime.session_id = None; + runtime.agent_turn_id = None; + runtime.expected_durable_revision = Some(settlement.after_revision.clone()); + }) + .await?; + self.release_repository(&updated).await; + self.schedule_next_for_repository( + &updated.identity.item.repository.canonical_id(), + Some(&updated.task_id), + ) + .await; + if final_state == LoopxTaskState::Queued { + let task_id = task.task_id.clone(); + let delay = settlement.scheduler_hint_ms.unwrap_or(0); + self.enqueue_task(task_id, Duration::from_millis(delay))?; + } + Ok(()) + } + + async fn reserve_repository(&self, task: &LoopxTaskSnapshot) -> bool { + let repo = task.identity.item.repository.canonical_id(); + let mut active = self.active_repositories.lock().await; + match active.get(&repo) { + Some(owner) => owner == &task.task_id, + None => { + active.insert(repo, task.task_id.clone()); + true + } + } + } + + async fn release_repository(&self, task: &LoopxTaskSnapshot) { + let repo = task.identity.item.repository.canonical_id(); + let mut active = self.active_repositories.lock().await; + if active.get(&repo) == Some(&task.task_id) { + active.remove(&repo); + } + } + + async fn update_environment( + &self, + status: LoopxEnvironmentStatus, + manifest: Option, + github_auth: Option, + ) -> Result<(), String> { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + state.environment.revision = state.environment.revision.saturating_add(1); + state.environment.status = status; + state.environment.checked_at = Some(now_ms()); + if let Some(manifest) = manifest { + state.environment.core.sidecar = LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Available, + version: Some(manifest.loopx_version), + detail: Some(manifest.executable.identity), + checked_at: state.environment.checked_at, + ..LoopxEnvironmentFact::default() + }; + } + if let Some(github_auth) = github_auth { + state.environment.optional.github_auth = LoopxEnvironmentFact { + status: github_auth_fact_status(&github_auth), + detail: github_auth.detail, + checked_at: state.environment.checked_at, + ..LoopxEnvironmentFact::default() + }; + } + state.revision = state.revision.saturating_add(1); + state.append_event(LoopxEvent { + kind: LoopxEventKind::EnvironmentChanged, + source: LoopxEventSource::System, + message: format!("LoopX environment status changed to {status:?}"), + occurred_at: now_ms(), + ..LoopxEvent::default() + }); + let persisted = state.clone(); + let event = persisted.events.last().cloned(); + drop(state); + self.store.save(&persisted).await?; + if let Some(event) = event { + let _ = self.event_sender.send(event); + } + Ok(()) + } + + async fn update_environment_error(&self, error: String) -> Result<(), String> { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + state.environment.revision = state.environment.revision.saturating_add(1); + state.environment.status = LoopxEnvironmentStatus::Blocked; + state.environment.checked_at = Some(now_ms()); + state.environment.core.sidecar = LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Unavailable, + detail: Some(error.clone()), + checked_at: state.environment.checked_at, + ..LoopxEnvironmentFact::default() + }; + state.revision = state.revision.saturating_add(1); + state.append_event(LoopxEvent { + kind: LoopxEventKind::EnvironmentChanged, + level: LoopxEventLevel::Error, + source: LoopxEventSource::System, + message: error, + important: true, + occurred_at: now_ms(), + ..LoopxEvent::default() + }); + let persisted = state.clone(); + let event = persisted.events.last().cloned(); + drop(state); + self.store.save(&persisted).await?; + if let Some(event) = event { + let _ = self.event_sender.send(event); + } + Ok(()) + } + + async fn record_progress(&self, progress: Vec) -> Result<(), String> { + if progress.is_empty() { + return Ok(()); + } + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let start_cursor = state.cursor; + for item in progress { + state.append_event(LoopxEvent { + task_id: item.task_id, + kind: LoopxEventKind::Progress, + source: LoopxEventSource::Sidecar, + message: item.message, + occurred_at: item.occurred_at, + ..LoopxEvent::default() + }); + } + let persisted = state.clone(); + drop(state); + self.store.save(&persisted).await?; + self.broadcast_new_events(&persisted, start_cursor); + Ok(()) + } + + async fn bind_workspace( + &self, + task_id: &str, + generation: u64, + workspace: &LoopxWorkspacePrepareResult, + ) -> Result<(), String> { + self.mutate_task(task_id, None, |task, runtime| { + if task.generation != generation { + return; + } + task.workspace_path = Some(workspace.worktree_path.clone()); + task.phase = LoopxPhase::CreatingGoal; + task.revision = task.revision.saturating_add(1); + runtime.registry_path = workspace.registry_path.clone(); + }) + .await + .map(|_| ()) + } + + async fn bind_goal( + &self, + task_id: &str, + generation: u64, + goal: LoopxCliCreateGoalResult, + ) -> Result<(), String> { + self.mutate_task(task_id, None, |task, runtime| { + if task.generation != generation { + return; + } + task.goal_id = Some(goal.goal_id.clone()); + task.state = LoopxTaskState::Queued; + task.phase = LoopxPhase::InspectingGoal; + task.revision = task.revision.saturating_add(1); + runtime.expected_durable_revision = Some(goal.durable_revision.clone()); + }) + .await + .map(|_| ()) + } + + async fn bind_turn( + &self, + task: &LoopxTaskSnapshot, + turn: &LoopxCliBuildTurnResult, + ) -> Result<(), String> { + let generation = task.generation; + self.mutate_task(&task.task_id, None, |task, runtime| { + if task.generation != generation { + return; + } + task.phase = LoopxPhase::StartingAgent; + task.deadline_at = turn.deadline_at; + task.current_turn_id = Some(turn.turn_id.clone()); + task.revision = task.revision.saturating_add(1); + runtime.loopx_turn_id = Some(turn.turn_id.clone()); + runtime.settlement_token = Some(turn.settlement_token.clone()); + runtime.expected_durable_revision = Some(turn.durable_revision.clone()); + }) + .await + .map(|_| ()) + } + + async fn bind_agent_run( + &self, + task: &LoopxTaskSnapshot, + run: LoopxAgentStartResult, + ) -> Result<(), String> { + self.mutate_task(&task.task_id, None, |task, runtime| { + task.state = LoopxTaskState::Running; + task.phase = LoopxPhase::AgentRunning; + task.current_turn_id = Some(run.turn_id.clone()); + task.last_output_at = Some(now_ms()); + task.revision = task.revision.saturating_add(1); + runtime.session_id = Some(run.session_id.clone()); + runtime.agent_turn_id = Some(run.turn_id.clone()); + }) + .await + .map(|_| ()) + } + + async fn transition_task( + &self, + task_id: &str, + generation: u64, + state: LoopxTaskState, + phase: LoopxPhase, + message: &str, + ) -> Result<(), String> { + let updated = self + .mutate_task(task_id, None, |task, _| { + if task.generation != generation { + return; + } + task.state = state; + task.phase = phase; + task.revision = task.revision.saturating_add(1); + }) + .await?; + self.append_task_event( + &updated, + LoopxEventKind::StateChanged, + message, + state == LoopxTaskState::RecoveryRequired, + ) + .await + } + + async fn transition_action( + &self, + task_id: &str, + state: LoopxTaskState, + phase: LoopxPhase, + request_id: &str, + ) -> Result { + let updated = self + .mutate_task(task_id, Some(request_id), |task, _| { + task.state = state; + task.phase = phase; + task.revision = task.revision.saturating_add(1); + }) + .await?; + Ok(LoopxActionResponse { + current_revision: updated.revision, + task: Some(updated), + ..LoopxActionResponse::default() + }) + } + + async fn update_task_phase( + &self, + task_id: &str, + generation: u64, + phase: LoopxPhase, + message: &str, + ) -> Result<(), String> { + let updated = self + .mutate_task(task_id, None, |task, _| { + if task.generation != generation { + return; + } + task.phase = phase; + task.revision = task.revision.saturating_add(1); + }) + .await?; + self.append_task_event(&updated, LoopxEventKind::PhaseChanged, message, false) + .await + } + + async fn fail_task(self: &Arc, task_id: &str, error: String) -> Result<(), String> { + let updated = self + .mutate_task(task_id, None, |task, _| { + task.state = LoopxTaskState::RecoveryRequired; + task.phase = LoopxPhase::Recovering; + task.error = Some(error.clone()); + task.deadline_at = None; + task.revision = task.revision.saturating_add(1); + }) + .await?; + self.release_repository(&updated).await; + self.append_task_event(&updated, LoopxEventKind::StateChanged, &error, true) + .await?; + self.schedule_next_for_repository( + &updated.identity.item.repository.canonical_id(), + Some(&updated.task_id), + ) + .await; + Ok(()) + } + + async fn schedule_next_for_repository( + self: &Arc, + repository_id: &str, + exclude_task_id: Option<&str>, + ) { + let next = { + let state = self.state.read().await; + state + .tasks + .iter() + .find(|task| { + task.state == LoopxTaskState::Queued + && task.identity.item.repository.canonical_id() == repository_id + && exclude_task_id != Some(task.task_id.as_str()) + }) + .map(|task| task.task_id.clone()) + }; + if let Some(task_id) = next { + let _ = self.enqueue_task(task_id, Duration::ZERO); + } + } + + fn enqueue_task(&self, task_id: String, delay: Duration) -> Result<(), String> { + self.task_sender + .send(ScheduledTask { task_id, delay }) + .map_err(|_| "LoopX controller task runner is unavailable".to_string()) + } + + async fn mutate_task( + &self, + task_id: &str, + request_id: Option<&str>, + update: impl FnOnce(&mut LoopxTaskSnapshot, &mut LoopxTaskRuntimeRecord), + ) -> Result { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let task_index = state + .tasks + .iter() + .position(|task| task.task_id == task_id) + .ok_or_else(|| "LoopX task not found".to_string())?; + let mut runtime = state.runtime.remove(task_id).unwrap_or_default(); + update(&mut state.tasks[task_index], &mut runtime); + state.tasks[task_index].updated_at = now_ms(); + let updated = state.tasks[task_index].clone(); + state.runtime.insert(task_id.to_string(), runtime); + state.revision = state.revision.saturating_add(1); + if let Some(request_id) = request_id { + state.record_processed_request(request_id.to_string()); + } + let persisted = state.clone(); + drop(state); + self.store.save(&persisted).await?; + Ok(updated) + } + + async fn append_task_event( + &self, + task: &LoopxTaskSnapshot, + kind: LoopxEventKind, + message: &str, + important: bool, + ) -> Result<(), String> { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + state.append_event(LoopxEvent { + task_id: Some(task.task_id.clone()), + generation: Some(task.generation), + revision: Some(task.revision), + kind, + level: if important { + LoopxEventLevel::Error + } else { + LoopxEventLevel::Info + }, + source: LoopxEventSource::Controller, + phase: Some(task.phase), + message: message.to_string(), + important, + occurred_at: now_ms(), + ..LoopxEvent::default() + }); + let persisted = state.clone(); + let event = persisted.events.last().cloned(); + drop(state); + self.store.save(&persisted).await?; + if let Some(event) = event { + let _ = self.event_sender.send(event); + } + Ok(()) + } + + async fn task(&self, task_id: &str) -> Result { + self.state + .read() + .await + .tasks + .iter() + .find(|task| task.task_id == task_id) + .cloned() + .ok_or_else(|| "LoopX task not found".to_string()) + } + + async fn runtime(&self, task_id: &str) -> LoopxTaskRuntimeRecord { + self.state + .read() + .await + .runtime + .get(task_id) + .cloned() + .unwrap_or_default() + } + + async fn ensure_writable(&self) -> Result<(), String> { + match self.load_error.read().await.clone() { + Some(error) => Err(error), + None => Ok(()), + } + } + + async fn persist_current(&self) -> Result<(), String> { + let state = self.state.read().await.clone(); + self.store.save(&state).await + } + + fn broadcast_new_events(&self, state: &LoopxPersistedState, after_cursor: u64) { + for event in state + .events + .iter() + .filter(|event| event.cursor > after_cursor) + { + let _ = self.event_sender.send(event.clone()); + } + } +} + +fn goal_context(task: &LoopxTaskSnapshot, runtime: &LoopxTaskRuntimeRecord) -> LoopxCliGoalContext { + LoopxCliGoalContext { + call: LoopxCliCallContext { + operation_id: runtime.operation_id.clone(), + deadline_at: task.deadline_at, + }, + task_id: task.task_id.clone(), + generation: task.generation, + worktree_path: task.workspace_path.clone().unwrap_or_default(), + registry_path: runtime.registry_path.clone(), + } +} + +fn goal_id_for(identity: &LoopxTaskIdentity) -> String { + let item = &identity.item; + let kind = match item.kind { + LoopxItemKind::Issue => "issue", + LoopxItemKind::PullRequest => "pr", + }; + let suffix = if identity.attempt > 1 { + format!("-{}", identity.attempt) + } else { + String::new() + }; + format!( + "bfx-{}-{}-{kind}-{}{}", + item.repository.owner, item.repository.repository, item.number, suffix + ) +} + +fn existing_outcomes( + state: &LoopxPersistedState, + selected: &std::collections::BTreeSet, +) -> Vec { + selected + .iter() + .map(|item| { + let task = state + .tasks + .iter() + .filter(|task| &task.identity.item == item) + .max_by_key(|task| task.identity.attempt); + LoopxCreateTaskOutcome { + item: item.clone(), + kind: LoopxCreateTaskOutcomeKind::OpenedExisting, + task_id: task.map(|task| task.task_id.clone()), + attempt: task.map(|task| task.identity.attempt), + ..LoopxCreateTaskOutcome::default() + } + }) + .collect() +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +fn github_auth_fact_status(probe: &LoopxGithubAuthProbe) -> LoopxEnvironmentFactStatus { + if probe.authenticated { + LoopxEnvironmentFactStatus::Available + } else if probe.rate_limit_remaining.is_some() { + LoopxEnvironmentFactStatus::Degraded + } else { + LoopxEnvironmentFactStatus::Unavailable + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn goal_ids_are_per_item_and_attempt() { + let identity = LoopxTaskIdentity { + item: LoopxIssueKey { + repository: LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "owner".to_string(), + repository: "repo".to_string(), + }, + kind: LoopxItemKind::Issue, + number: 42, + }, + attempt: 2, + }; + assert_eq!(goal_id_for(&identity), "bfx-owner-repo-issue-42-2"); + } +} diff --git a/src/crates/assembly/core/src/miniapp/loopx/mod.rs b/src/crates/assembly/core/src/miniapp/loopx/mod.rs new file mode 100644 index 0000000000..17f1fdd4a6 --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/mod.rs @@ -0,0 +1,9 @@ +mod agent_adapter; +mod controller; +mod store; +mod subscriber; + +pub use controller::LoopxController; +pub use store::{LoopxPersistedState, LoopxStateStore, LoopxTaskRuntimeRecord}; +pub use subscriber::LoopxEventSubscriber; +pub use agent_adapter::CoreLoopxAgentPort; diff --git a/src/crates/assembly/core/src/miniapp/loopx/store.rs b/src/crates/assembly/core/src/miniapp/loopx/store.rs new file mode 100644 index 0000000000..53941b0001 --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/store.rs @@ -0,0 +1,271 @@ +use bitfun_product_domains::miniapp::loopx::{ + task_state_after_restart, LoopxEnvironmentSnapshot, LoopxEvent, LoopxEventKind, + LoopxEventLevel, LoopxEventSource, LoopxEventsPageStatus, LoopxEventsSinceResponse, + LoopxExecutionDomain, LoopxExecutionSupport, LoopxPhase, LoopxSnapshot, LoopxTaskSnapshot, +}; +use bitfun_services_core::json_store::JsonFileStore; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +const LOOPX_STATE_SCHEMA_VERSION: u32 = 1; +const MAX_SEMANTIC_EVENTS: usize = 2_000; +const MAX_IDEMPOTENCY_KEYS: usize = 512; +const DEFAULT_EVENT_PAGE_SIZE: usize = 200; +const MAX_EVENT_PAGE_SIZE: usize = 1_000; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxPersistedState { + pub schema_version: u32, + pub stream_id: String, + pub cursor: u64, + pub revision: u64, + pub environment: LoopxEnvironmentSnapshot, + pub tasks: Vec, + pub runtime: BTreeMap, + pub events: Vec, + pub processed_request_ids: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxTaskRuntimeRecord { + pub operation_id: String, + pub registry_path: String, + pub session_id: Option, + pub agent_turn_id: Option, + pub loopx_turn_id: Option, + pub settlement_token: Option, + pub expected_durable_revision: Option, +} + +impl Default for LoopxPersistedState { + fn default() -> Self { + Self::new(0) + } +} + +impl LoopxPersistedState { + pub fn new(now_ms: i64) -> Self { + Self { + schema_version: LOOPX_STATE_SCHEMA_VERSION, + stream_id: uuid::Uuid::new_v4().to_string(), + cursor: 0, + revision: 0, + environment: LoopxEnvironmentSnapshot::default(), + tasks: Vec::new(), + runtime: BTreeMap::new(), + events: Vec::new(), + processed_request_ids: Vec::new(), + } + .with_generated_time(now_ms) + } + + fn with_generated_time(mut self, now_ms: i64) -> Self { + self.environment.checked_at = (now_ms > 0).then_some(now_ms); + self + } + + pub fn snapshot( + &self, + execution_domain: LoopxExecutionDomain, + execution_support: LoopxExecutionSupport, + unsupported_reason: Option, + now_ms: i64, + ) -> LoopxSnapshot { + LoopxSnapshot { + schema_version: self.schema_version, + stream_id: self.stream_id.clone(), + cursor: self.cursor, + revision: self.revision, + execution_domain, + execution_support, + unsupported_reason, + environment: self.environment.clone(), + tasks: self.tasks.clone(), + generated_at: now_ms, + } + } + + pub fn apply_restart_policy(&mut self, now_ms: i64) -> bool { + let mut changed = false; + for task in &mut self.tasks { + let restarted = task_state_after_restart(task.state); + if restarted == task.state { + continue; + } + task.state = restarted; + task.phase = LoopxPhase::Recovering; + task.revision = task.revision.saturating_add(1); + task.updated_at = now_ms; + task.current_tool = None; + task.deadline_at = None; + task.retry_at = None; + changed = true; + } + if changed { + self.revision = self.revision.saturating_add(1); + self.append_event(LoopxEvent { + kind: LoopxEventKind::SnapshotInvalidated, + level: LoopxEventLevel::Warning, + source: LoopxEventSource::Controller, + phase: Some(LoopxPhase::Recovering), + message: "Host restarted; unfinished LoopX tasks require explicit recovery" + .to_string(), + important: true, + occurred_at: now_ms, + ..LoopxEvent::default() + }); + } + changed + } + + pub fn append_event(&mut self, mut event: LoopxEvent) { + self.cursor = self.cursor.saturating_add(1); + event.stream_id = self.stream_id.clone(); + event.cursor = self.cursor; + self.events.push(event); + if self.events.len() > MAX_SEMANTIC_EVENTS { + let remove = self.events.len() - MAX_SEMANTIC_EVENTS; + self.events.drain(0..remove); + } + } + + pub fn events_since( + &self, + stream_id: &str, + after_cursor: u64, + requested_limit: Option, + ) -> LoopxEventsSinceResponse { + if stream_id != self.stream_id { + return LoopxEventsSinceResponse { + status: LoopxEventsPageStatus::SnapshotRequired, + stream_id: self.stream_id.clone(), + next_cursor: self.cursor, + ..LoopxEventsSinceResponse::default() + }; + } + + let limit = requested_limit + .map(|value| value as usize) + .unwrap_or(DEFAULT_EVENT_PAGE_SIZE) + .clamp(1, MAX_EVENT_PAGE_SIZE); + let mut available = self + .events + .iter() + .filter(|event| event.cursor > after_cursor); + let events = available.by_ref().take(limit).cloned().collect::>(); + let has_more = available.next().is_some(); + let next_cursor = events + .last() + .map(|event| event.cursor) + .unwrap_or(after_cursor.min(self.cursor)); + LoopxEventsSinceResponse { + status: LoopxEventsPageStatus::Current, + stream_id: self.stream_id.clone(), + events, + next_cursor, + has_more, + } + } + + pub fn has_processed_request(&self, request_id: &str) -> bool { + self.processed_request_ids + .iter() + .any(|existing| existing == request_id) + } + + pub fn record_processed_request(&mut self, request_id: String) { + if request_id.is_empty() || self.has_processed_request(&request_id) { + return; + } + self.processed_request_ids.push(request_id); + if self.processed_request_ids.len() > MAX_IDEMPOTENCY_KEYS { + let remove = self.processed_request_ids.len() - MAX_IDEMPOTENCY_KEYS; + self.processed_request_ids.drain(0..remove); + } + } +} + +#[derive(Debug, Clone)] +pub struct LoopxStateStore { + path: PathBuf, + json: JsonFileStore, +} + +impl LoopxStateStore { + pub fn new(path: PathBuf) -> Self { + Self { + path, + json: JsonFileStore, + } + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub async fn load(&self) -> Result, String> { + self.json + .read_locked_optional(&self.path) + .await + .map_err(|error| format!("Failed to load LoopX task state: {error}")) + } + + pub async fn save(&self, state: &LoopxPersistedState) -> Result<(), String> { + self.json + .write_atomic_strict(&self.path, state) + .await + .map_err(|error| format!("Failed to persist LoopX task state: {error}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_product_domains::miniapp::loopx::{LoopxTaskState, LoopxTaskSnapshot}; + + #[tokio::test] + async fn restart_requires_explicit_recovery_and_round_trips() { + let root = tempfile::tempdir().expect("tempdir"); + let store = LoopxStateStore::new(root.path().join("loopx-state.json")); + let mut state = LoopxPersistedState::new(10); + state.tasks.push(LoopxTaskSnapshot { + task_id: "task-1".to_string(), + state: LoopxTaskState::Running, + phase: LoopxPhase::AgentRunning, + revision: 3, + created_at: 1, + updated_at: 2, + ..LoopxTaskSnapshot::default() + }); + assert!(state.apply_restart_policy(20)); + store.save(&state).await.expect("save"); + + let loaded = store.load().await.expect("load").expect("state"); + assert_eq!(loaded.tasks[0].state, LoopxTaskState::RecoveryRequired); + assert_eq!(loaded.tasks[0].revision, 4); + assert_eq!(loaded.events[0].kind, LoopxEventKind::SnapshotInvalidated); + } + + #[test] + fn cursor_gap_and_pagination_are_explicit() { + let mut state = LoopxPersistedState::new(1); + for index in 0..3 { + state.append_event(LoopxEvent { + message: format!("event-{index}"), + ..LoopxEvent::default() + }); + } + + let wrong = state.events_since("old-stream", 0, None); + assert_eq!(wrong.status, LoopxEventsPageStatus::SnapshotRequired); + let first = state.events_since(&state.stream_id, 0, Some(2)); + assert_eq!(first.events.len(), 2); + assert!(first.has_more); + let second = state.events_since(&state.stream_id, first.next_cursor, Some(2)); + assert_eq!(second.events.len(), 1); + assert!(!second.has_more); + } +} diff --git a/src/crates/assembly/core/src/miniapp/loopx/subscriber.rs b/src/crates/assembly/core/src/miniapp/loopx/subscriber.rs new file mode 100644 index 0000000000..2a346e426f --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/subscriber.rs @@ -0,0 +1,73 @@ +use super::LoopxController; +use crate::agentic::events::{AgenticEvent, EventSubscriber}; +use bitfun_agent_runtime::event_bus::{EventBusError, EventSubscriberResult}; +use bitfun_product_domains::miniapp::loopx::LoopxAgentTurnStatus; +use std::sync::Arc; + +pub struct LoopxEventSubscriber { + controller: Arc, +} + +impl LoopxEventSubscriber { + pub fn new(controller: Arc) -> Self { + Self { controller } + } +} + +#[async_trait::async_trait] +impl EventSubscriber for LoopxEventSubscriber { + async fn on_event(&self, event: &AgenticEvent) -> EventSubscriberResult { + let result = match event { + AgenticEvent::TextChunk { turn_id, .. } + | AgenticEvent::ThinkingChunk { turn_id, .. } + | AgenticEvent::ModelRoundStarted { turn_id, .. } => { + self.controller.handle_agent_activity(turn_id, None).await + } + AgenticEvent::ToolEvent { + turn_id, + tool_event, + .. + } => { + self.controller + .handle_agent_activity( + turn_id, + Some(tool_event.effective_tool_name().to_string()), + ) + .await + } + AgenticEvent::DialogTurnCompleted { + turn_id, success, .. + } => { + let status = if *success == Some(false) { + LoopxAgentTurnStatus::Failed + } else { + LoopxAgentTurnStatus::Completed + }; + self.controller + .handle_agent_terminal(turn_id, status, None) + .await + } + AgenticEvent::DialogTurnFailed { turn_id, error, .. } => { + self.controller + .handle_agent_terminal( + turn_id, + LoopxAgentTurnStatus::Failed, + Some(error.clone()), + ) + .await + } + AgenticEvent::DialogTurnCancelled { turn_id, .. } => { + self.controller + .handle_agent_terminal(turn_id, LoopxAgentTurnStatus::Cancelled, None) + .await + } + AgenticEvent::DialogTurnInterrupted { turn_id, .. } => { + self.controller + .handle_agent_terminal(turn_id, LoopxAgentTurnStatus::Interrupted, None) + .await + } + _ => Ok(()), + }; + result.map_err(EventBusError::subscriber) + } +} diff --git a/src/crates/assembly/core/src/miniapp/mod.rs b/src/crates/assembly/core/src/miniapp/mod.rs index fc2a7bdc74..60ded4690b 100644 --- a/src/crates/assembly/core/src/miniapp/mod.rs +++ b/src/crates/assembly/core/src/miniapp/mod.rs @@ -7,6 +7,8 @@ pub mod exporter; pub mod host_dispatch; pub mod js_worker; pub mod js_worker_pool; +#[cfg(feature = "agent-runtime")] +pub mod loopx; pub mod manager; pub mod runtime_detect; pub mod storage; @@ -18,6 +20,7 @@ pub use bitfun_product_domains::miniapp::draft::{MiniAppDraft, MiniAppDraftManif pub use bitfun_product_domains::miniapp::{ agent_bridge, ai_bridge, bridge_builder, lifecycle, permission_policy, rate_limit, types, }; +pub use bitfun_product_domains::miniapp::builtin::builtin_content_hash; pub use builtin::{seed_builtin_miniapps, BuiltinApp, BUILTIN_APPS}; pub use exporter::{ExportCheckResult, ExportOptions, ExportResult, ExportTarget, MiniAppExporter}; diff --git a/src/crates/contracts/events/src/frontend_projection.rs b/src/crates/contracts/events/src/frontend_projection.rs index dd37697889..df0378c46b 100644 --- a/src/crates/contracts/events/src/frontend_projection.rs +++ b/src/crates/contracts/events/src/frontend_projection.rs @@ -154,6 +154,7 @@ pub fn project_agentic_frontend_event(event: AgenticEvent) -> Option String { - format!("miniapp-agent-{}-{}", app_id, sequence) + // The process-local sequence resets on restart while a reused hidden + // session keeps its persisted dialog_turn_ids. A restarted host would + // otherwise regenerate "miniapp-agent-{app_id}-1" and collide with a turn + // already recorded on that session ("Dialog turn already exists"). Anchor + // the id to wall-clock time so it stays unique across restarts. + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + format!("miniapp-agent-{}-{}-{}", app_id, sequence, stamp) } pub fn agent_run_id_from_request( @@ -474,9 +483,10 @@ mod tests { agent_run_id_from_request("app-1", Some(" run-1 "), 9), "run-1" ); - assert_eq!( - agent_run_id_from_request("app-1", Some(" "), 9), - "miniapp-agent-app-1-9" + let generated = agent_run_id_from_request("app-1", Some(" "), 9); + assert!( + generated.starts_with("miniapp-agent-app-1-9-"), + "expected a restart-unique run id, got: {generated}" ); let plan = build_agent_submission_plan( diff --git a/src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs b/src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs index d7088beeb4..57fc294cf6 100644 --- a/src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs +++ b/src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs @@ -5,7 +5,7 @@ use serde_json; /// Build the Runtime Adapter script (JS) to inject into the iframe. /// Exposes window.app with call(), fs.*, shell.*, net.*, os.*, storage.*, dialog.*, -/// ai.*, agent.*, deck.*, chat.*, clipboard.*, lifecycle, events. +/// ai.*, agent.*, loopx.*, deck.*, chat.*, clipboard.*, lifecycle, events. pub fn build_bridge_script( app_id: &str, app_data_dir: &str, @@ -131,6 +131,21 @@ pub fn build_bridge_script( offEvent: (fn) => app.off('agent:event', fn), }}, + // Built-in LoopX console namespace. The outer bridge and Desktop host + // verify the exact built-in identity, active runner scope, content hash, + // customization origin, and local execution domain before accepting any + // method. Other MiniApps receive an explicit unsupported response. + loopx: {{ + attach: (opts) => _rpc('loopx.attach', opts || {{}}), + listModels: () => _rpc('loopx.listModels', {{}}), + resolveIntake: (opts) => _rpc('loopx.resolveIntake', opts || {{}}), + createTask: (opts) => _rpc('loopx.createTask', opts || {{}}), + action: (opts) => _rpc('loopx.action', opts || {{}}), + eventsSince: (opts) => _rpc('loopx.eventsSince', opts || {{}}), + onEvent: (fn) => app.on('loopx:event', fn), + offEvent: (fn) => app.off('loopx:event', fn), + }}, + // Deck namespace — renders one slide HTML page in a hidden host WebView // and returns base64 PNG/PDF. Used by presentation MiniApps for // page-by-page export rasterization. diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin.rs b/src/crates/contracts/product-domains/src/miniapp/builtin.rs index 693aec8c4d..0bb11d669a 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin.rs +++ b/src/crates/contracts/product-domains/src/miniapp/builtin.rs @@ -8,7 +8,7 @@ use crate::miniapp::ports::{MiniAppPortFuture, MiniAppPortResult}; use crate::miniapp::storage::{ build_package_json, ESM_DEPS_JSON, INDEX_HTML, STYLE_CSS, UI_JS, WORKER_JS, }; -use crate::miniapp::types::MiniAppMeta; +use crate::miniapp::types::{EsmDep, MiniAppMeta, MiniAppSource}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -159,6 +159,16 @@ pub const BUILTIN_APPS: &[BuiltinMiniAppBundle] = &[ worker_js: include_str!("builtin/assets/ppt-live/worker.js"), esm_dependencies_json: include_str!("builtin/assets/ppt-live/esm_dependencies.json"), }, + BuiltinMiniAppBundle { + id: "builtin-bitfun-loopx", + version: 4, + meta_json: include_str!("builtin/assets/bitfun-loopx/meta.json"), + html: include_str!("builtin/assets/bitfun-loopx/index.html"), + css: include_str!("builtin/assets/bitfun-loopx/style.css"), + ui_js: include_str!("builtin/assets/bitfun-loopx/ui.js"), + worker_js: include_str!("builtin/assets/bitfun-loopx/worker.js"), + esm_dependencies_json: include_str!("builtin/assets/bitfun-loopx/esm_dependencies.json"), + }, ]; pub fn builtin_content_hash(app: &BuiltinMiniAppBundle) -> String { @@ -176,6 +186,23 @@ pub fn builtin_content_hash(app: &BuiltinMiniAppBundle) -> String { format!("sha256:{}", hex_encode(&hasher.finalize())) } +/// Whether installed source files still match the bundled built-in assets +/// verbatim. Privileged host bridges use this to refuse driving from locally +/// modified built-in content. The meta.json identity/timestamp rewrite performed +/// at seed time is intentionally excluded. +pub fn builtin_source_matches(source: &MiniAppSource, bundle: &BuiltinMiniAppBundle) -> bool { + let Ok(bundled_esm_deps) = serde_json::from_str::>(bundle.esm_dependencies_json) + else { + return false; + }; + source.html == bundle.html + && source.css == bundle.css + && source.ui_js == bundle.ui_js + && source.worker_js == bundle.worker_js + && source.esm_dependencies == bundled_esm_deps + && source.npm_dependencies.is_empty() +} + pub fn build_builtin_install_marker( app: &BuiltinMiniAppBundle, content_hash: &str, @@ -349,9 +376,9 @@ mod tests { // Version bumps should only touch bundle registration and seed runtime, not tests. use super::{ - build_builtin_seed_artifacts, builtin_content_hash, seed_builtin_miniapp_with_host, - BuiltinInstallMarker, BuiltinMiniAppSeedBundleRequest, BuiltinMiniAppSeedHost, - BuiltinMiniAppSeedOutcome, BuiltinSeedArtifacts, BUILTIN_APPS, + build_builtin_seed_artifacts, builtin_content_hash, builtin_source_matches, + seed_builtin_miniapp_with_host, BuiltinInstallMarker, BuiltinMiniAppSeedBundleRequest, + BuiltinMiniAppSeedHost, BuiltinMiniAppSeedOutcome, BuiltinSeedArtifacts, BUILTIN_APPS, }; use crate::miniapp::ports::{MiniAppPortFuture, MiniAppPortResult}; use std::sync::{Arc, Mutex}; @@ -368,6 +395,7 @@ mod tests { "builtin-regex-playground", "builtin-coding-selfie", "builtin-ppt-live", + "builtin-bitfun-loopx", ] ); @@ -381,6 +409,56 @@ mod tests { } } + #[test] + fn builtin_miniapp_meta_and_deps_parse_into_product_types() { + use crate::miniapp::types::{EsmDep, MiniAppMeta}; + + for app in BUILTIN_APPS { + let meta: MiniAppMeta = serde_json::from_str(app.meta_json).unwrap_or_else(|e| { + panic!("builtin '{}' meta.json does not parse as MiniAppMeta: {e}", app.id) + }); + assert_eq!(meta.id, app.id, "builtin '{}' meta id must match the bundle id", app.id); + assert!( + !meta.name.trim().is_empty(), + "builtin '{}' meta name must not be empty", + app.id + ); + assert!( + !meta.icon.trim().is_empty(), + "builtin '{}' meta icon must not be empty", + app.id + ); + let i18n = meta.i18n.as_ref().unwrap_or_else(|| { + panic!("builtin '{}' meta.json must carry i18n locales", app.id) + }); + assert!( + i18n.locales.contains_key("zh-CN"), + "builtin '{}' i18n must cover zh-CN", + app.id + ); + assert!( + i18n.locales.contains_key("en-US"), + "builtin '{}' i18n must cover en-US", + app.id + ); + + let deps: Vec = serde_json::from_str(app.esm_dependencies_json) + .unwrap_or_else(|e| { + panic!( + "builtin '{}' esm_dependencies.json does not parse as an ESM dep list: {e}", + app.id + ) + }); + for dep in &deps { + assert!( + !dep.name.trim().is_empty(), + "builtin '{}' has an ESM dependency without a name", + app.id + ); + } + } + } + #[derive(Default)] struct FakeSeedHost { now_ms: i64, @@ -634,4 +712,32 @@ mod tests { assert!(!app.html.contains("href=\"./style.css\"")); assert!(app.css.contains("--bitfun-bg")); } + + #[test] + fn builtin_source_matches_detects_modified_source() { + use crate::miniapp::types::{EsmDep, MiniAppSource}; + + let app = &BUILTIN_APPS[0]; + let pristine = MiniAppSource { + html: app.html.to_string(), + css: app.css.to_string(), + ui_js: app.ui_js.to_string(), + worker_js: app.worker_js.to_string(), + esm_dependencies: serde_json::from_str(app.esm_dependencies_json).unwrap(), + npm_dependencies: Vec::new(), + }; + assert!(builtin_source_matches(&pristine, app)); + + let mut modified_ui = pristine.clone(); + modified_ui.ui_js.push_str("\n// tampered"); + assert!(!builtin_source_matches(&modified_ui, app)); + + let mut modified_deps = pristine; + modified_deps.esm_dependencies.push(EsmDep { + name: "extra".to_string(), + version: None, + url: None, + }); + assert!(!builtin_source_matches(&modified_deps, app)); + } } diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/README.md b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/README.md new file mode 100644 index 0000000000..27f42d78a2 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/README.md @@ -0,0 +1,117 @@ +# bitfun-loopx(内置 MiniApp) + +内置版 **bitfun-loopx**:粘贴 GitHub Issue 链接,由 BitFun 宿主 Agent 驱动本机 +LoopX 引擎持续修复,心跳调度、人工审批、中途插话。 + +本目录就是该内置 MiniApp 的**唯一权威源码**,不依赖任何外部仓库快照。五件套 +`index.html` / `style.css` / `ui.js` / `worker.js` / `esm_dependencies.json` 由 +`src/crates/contracts/product-domains/src/miniapp/builtin.rs` 的 `BUILTIN_APPS` +以 `include_str!` 嵌入二进制,注册 id 为 `builtin-bitfun-loopx`(同文件契约测试 +含 id 顺序断言);`meta.json` 的权限与注册条目保持一致。 + +## 修改流程 + +直接编辑本目录文件(`index.html` / `style.css` / `ui.js` / `worker.js` / +`meta.json`): + +1. **开发期无需 bump version**:种子机制按内容哈希判定,重启应用即自动 reseed + (跳过条件 = 内容哈希一致 且 已装 version ≥ 内置 version;内容一变哈希必变)。 +2. **发布期(每个发布版)**:把 `builtin.rs` 的 `BUILTIN_APPS` version 与 + `meta.json` 的 `version` **同步 +1**。version 只是单调计数,唯一作用是给 + "本地定制"用户发更新通知,不代表内容新旧。 +3. 本地验证 reseed:重启应用(或删除 + `~/.bitfun/miniapps/builtin-bitfun-loopx/.builtin-manifest.json` 后重启), + 确认新资源落盘; +4. `cargo test -p bitfun-product-domains --features product-full builtin_miniapp` 全绿后提交。 + +规范依据:`MiniApp/Skills/miniapp-dev/SKILL.md` 的「内置小应用(builtin/assets/*) +维护规范」。 + +## 内置更新行为 + +- 内容哈希变化(无论 version 是否 bump)都会在下次启动时 reseed:用户机器上的 + 源文件被覆盖为最新内置版本; +- 用户对源文件的**手动本地修改会被覆盖**,除非该应用处于"本地定制(local + override)"状态——那时只记录"有可用更新"通知(可拒绝),不改动本地内容; +- `storage.json`(设置、GitHub Token、goal↔session 映射等)跨 reseed 始终保留。 + +## 权限与信任模型 + +`meta.json` 的 `permissions` 约束的是宿主桥接原语(`app.shell.exec` / +`app.net.fetch` / `app.fs.*` 等)。本应用的 worker 是**受信内置代码**,直接使用 +Node 的 `child_process` / `fs` / `https`:因此它实际会 spawn 的 `gh` / `winget` / +`powershell` / `curl` / `unzip` / `tar` / `taskkill` 等二进制,以及写入 +`~/.bitfun/bitfun-loopx/**` 的行为,不需要(也不会)出现在上述 allowlist 中。 +这是内置应用的特权模型;`node.enabled = true` 的应用本来也无法上架市场。 + +## 平台支持 + +- loopx 获取三平台通用,优先级:① 安装包内置的编译二进制(见下节「loopx 依赖 + 与合规」,宿主经 `BITFUN_RESOURCE_DIR` 传入,无需 Python/git/网络)→ ② 一键拉 + 源码到 `~/.bitfun/bitfun-loopx/vendor/loopx`(`python -m loopx.cli` / + `python3 -m loopx.cli` 直跑)→ ③ pip 安装兜底。 +- gh 获取:Windows 走 winget,失败回退 PowerShell 下载 release zip;macOS 走 + Homebrew,失败回退 curl + unzip 下载 release zip;Linux 无通用包管理器,直接 + curl + tar 下载 release tar.gz 到 `~/.bitfun/bitfun-loopx/bin/gh`。 +- `gh auth login` 交互窗口:Windows `cmd start`;macOS Terminal(osascript); + Linux 依次尝试 `x-terminal-emulator` / `gnome-terminal` / `konsole` / `xterm`, + 全部缺失时返回明确的手动指引,不做静默降级。 + +## 设计假设 + +- 单实例桌面使用:心跳与 run-once 注册表都是进程内状态,同时开两个 BitFun + 窗口打开本应用会双心跳、可能重复启动 turn。远程控制(手机/web 遥控)下的 + 审批弹窗触达亦未验证。v1 明确接受该假设。 +- 每轮 turn 由宿主 Agent 执行(`app.agent.run`),loopx 只负责心跳/配额/计划 + 与 todo 状态;本应用不在本地托管任何外部 CLI 宿主(codex 等)。 +- v1 不设 turn 预算/成本上限:心跳节奏由 loopx 配额控制,宿主 Agent 消耗的 + 模型额度由用户在 BitFun 侧自行管理。 + +## loopx 依赖与合规 + +- **内置编译二进制(随安装包分发)**:打包流程(`scripts/desktop-tauri-build.mjs`, + 即 `pnpm run desktop:build*` 的 bundle 路径)会先执行 `scripts/build-loopx.mjs`: + 构建期拉取 pin `v0.2.13` 的 loopx 源码并用 PyInstaller 编译单文件二进制,随 + tauri `bundle.resources` 作为 sidecar 分发(`resources/loopx/`)。桌面宿主把 + 资源目录经 `BITFUN_RESOURCE_DIR` 传给 MiniApp worker,探测时内置二进制优先, + 用户机器零依赖。`--no-bundle` 的 dev 构建不触发该步骤,走 vendor/pip 兜底。 + 生成的 `resources/loopx/` 目录在 `.gitignore` 中,二进制不进仓库;`manifest.json` + 记录版本、commit、内容哈希与构建工具链。 +- **MIT 再分发义务**:loopx 为 MIT(Copyright (c) 2026 LoopX contributors), + 允许以二进制形式再分发,义务为保留许可证与版权声明——`resources/loopx/` 随包 + 携带上游 `LICENSE` 与 `TRADEMARKS.md`,`THIRD_PARTY_NOTICES.md` 已收录 loopx + 条目。名称按 loopx [TRADEMARKS.md](https://github.com/huangruiteng/loopx/blob/main/TRADEMARKS.md) + 描述性使用,本应用是第三方集成,非 LoopX 官方出品。 +- **运行期兜底**:内置二进制缺失/损坏时回退 vendor 源码(`~/.bitfun/bitfun-loopx/vendor/loopx`) + 或 pip 安装;pin 保持一致(`LOOPX_VENDOR_REF` = `v0.2.13`),升级必须伴随 + loopx CLI JSON 契约的显式适配。 +- 本应用自身的 GitHub 凭据只存于本机应用存储(gh CLI 或粘贴的 PAT),不写入 git config。 + +## loopx 依赖升级 + +loopx 不随包分发源码,只 pin 一个经过验证的版本。**不要自动追新**:只有在确实 +需要新版能力/修复时才升级。 + +1. **读 release notes / CHANGELOG**,重点确认 CLI 的 `--format json` 输出契约 + (`quota should-run` / `heartbeat-prompt` / `todo` / `status` / `bootstrap` + 等被 worker 解析的结构)没有破坏性变更; +2. **改 pin**:`worker.js` 的 `LOOPX_VENDOR_REF`(sidecar 构建 `scripts/build-loopx.mjs` + 与 vendor 兜底共用同一个 pin); +3. **本机冒烟**:删除 `~/.bitfun/bitfun-loopx/vendor/loopx` 后走一次「拉取 loopx + 源码」重 pin,再跑一个 issue 全流程(intake → 心跳 → turn → todo 审批 → + PR 发布)验证 JSON 契约; +4. **回滚**:出问题只需把 `LOOPX_VENDOR_REF` 改回旧 tag;vendor 目录在下次 + ensureVendor 自动重新 pin,用户无需手动修复; +5. **随发布提交**:pin 变更与 `version` bump 一起进发布版(发布版自带的 + sidecar 二进制由打包构建重新编译)。 + +**升级经验(首批开发踩坑记录)**: + +- loopx 示例命令里的 `${LOOPX_TURN:?}` 是 bash 语法,Windows 的 PowerShell + 下会原样报错——agent 提示词里明确"这是可选参数,直接省略"; +- 中文 Windows 下 Python CLI 的 stdout 默认 GBK,会破坏 `JSON.parse`——worker + 统一注入 `PYTHONUTF8=1` + `PYTHONIOENCODING=utf-8`; +- `python -m loopx` 不存在(loopx 没有 `__main__.py`),模块形式必须指向 + `loopx.cli`; +- 首次运行时 `quota` 的 public-boundary 扫描以 loopx 源码目录为根,冷缓存下 + 单次扫描可超一分钟——控制台把扫描根指向专用的空目录保证心跳节奏。 diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/esm_dependencies.json b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/esm_dependencies.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/esm_dependencies.json @@ -0,0 +1 @@ +[] diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/index.html b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/index.html new file mode 100644 index 0000000000..6b0f687dff --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/index.html @@ -0,0 +1,270 @@ + + + + + + LoopX + + + + +
+
+
+ +
+ LoopX + 正在连接宿主 +
+
+ +
+ + + + + 模型 + + + +
+ + +
+ + + + +
+ + + + 环境 + -- + + + +
+
+
+

核心环境

+ 必需 +
+
+
+
+
+

增强能力

+ 可选 +
+
+
+ +
+
+ +
+ + +
+
+
+
+

运行日志

+ +
+

所有任务的关键事件

+
+
+
+ + +
+ + + +
+
+ + + +
+
+ +

暂无运行事件

+
+
    +
    + + +
    + 未连接 + -- + 0 +
    +
    +
    +
    + + +
    +
    +
    + 确认任务 +

    +
    + +
    +
    +
    +
    仓库
    --
    +
    工作区
    --
    +
    模型
    --
    +
    图片能力
    --
    +
    +
    +
    +

    Issue / PR

    + 0 +
    +
    +
    +
    +
    +

    本次权限

    + 逐项授权 +
    +
    +
    + +
    +
    + + +
    +
    +
    + + +
    +
    +
    + 新尝试 +

    已有终态任务

    +
    +
    +
    +

    +
    +
    + + +
    +
    +
    + + +
    +
    +
    + 审批门禁 +

    +
    +
    +
    +

    + +
    +
    + +
    + + +
    +
    +
    +
    + + + + diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/meta.json b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/meta.json new file mode 100644 index 0000000000..73e6c79685 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/meta.json @@ -0,0 +1,29 @@ +{ + "id": "builtin-bitfun-loopx", + "name": "bitfun-loopx", + "description": "bitfun-loopx:提交 GitHub Issue 或 PR,由 BitFun 宿主持久调度 LoopX,并集中查看环境、审批与可恢复日志。", + "icon": "rocket", + "category": "developer", + "tags": ["loopx", "agent", "scheduler", "monitor"], + "version": 4, + "created_at": 0, + "updated_at": 0, + "permissions": { + "node": { "enabled": false } + }, + "ai_context": null, + "i18n": { + "locales": { + "zh-CN": { + "name": "bitfun-loopx", + "description": "bitfun-loopx:提交 GitHub Issue 或 PR,由 BitFun 宿主持久调度 LoopX,并集中查看环境、审批与可恢复日志。", + "tags": ["loopx", "智能体", "调度", "监控"] + }, + "en-US": { + "name": "bitfun-loopx", + "description": "bitfun-loopx: submit a GitHub issue or PR, let the BitFun host schedule LoopX durably, and monitor environment, approvals, and replayable logs in one place.", + "tags": ["loopx", "agent", "scheduler", "monitor"] + } + } + } +} diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/style.css b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/style.css new file mode 100644 index 0000000000..064b9850dc --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/style.css @@ -0,0 +1,594 @@ +*, *::before, *::after { box-sizing: border-box; } + +:root { + color-scheme: dark; + --lx-bg: var(--bitfun-bg, #111316); + --lx-surface: var(--bitfun-bg-secondary, #181b20); + --lx-elevated: var(--bitfun-bg-elevated, #20242a); + --lx-muted-surface: var(--bitfun-bg-tertiary, #15181c); + --lx-element: var(--bitfun-element-bg, rgba(255, 255, 255, .055)); + --lx-hover: var(--bitfun-element-hover, rgba(255, 255, 255, .085)); + --lx-border: var(--bitfun-border-subtle, rgba(255, 255, 255, .10)); + --lx-border-strong: var(--bitfun-border, rgba(255, 255, 255, .17)); + --lx-text: var(--bitfun-text, #eef0f3); + --lx-text-soft: var(--bitfun-text-secondary, #b7bbc3); + --lx-text-muted: var(--bitfun-text-muted, #858b95); + --lx-accent: var(--bitfun-accent, #5b8def); + --lx-accent-hover: var(--bitfun-accent-hover, #477de8); + --lx-success: var(--bitfun-success, #35b779); + --lx-warning: var(--bitfun-warning, #e7a83c); + --lx-error: var(--bitfun-error, #e45b63); + --lx-info: var(--bitfun-info, #42a5c6); + --lx-radius: min(var(--bitfun-radius, 8px), 8px); + --lx-font: var(--bitfun-font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); + --lx-mono: var(--bitfun-font-mono, "Cascadia Mono", Consolas, monospace); +} + +[data-bf-appearance-mode="light"] { + color-scheme: light; + --lx-bg: var(--bitfun-bg, #f5f6f8); + --lx-surface: var(--bitfun-bg-secondary, #ffffff); + --lx-elevated: var(--bitfun-bg-elevated, #ffffff); + --lx-muted-surface: var(--bitfun-bg-tertiary, #eef0f3); + --lx-element: var(--bitfun-element-bg, rgba(18, 24, 34, .045)); + --lx-hover: var(--bitfun-element-hover, rgba(18, 24, 34, .07)); + --lx-border: var(--bitfun-border-subtle, rgba(18, 24, 34, .11)); + --lx-border-strong: var(--bitfun-border, rgba(18, 24, 34, .18)); + --lx-text: var(--bitfun-text, #20242a); + --lx-text-soft: var(--bitfun-text-secondary, #505866); + --lx-text-muted: var(--bitfun-text-muted, #727b89); + --lx-success: var(--bitfun-success, #218c5b); + --lx-warning: var(--bitfun-warning, #b97818); + --lx-error: var(--bitfun-error, #cf4650); + --lx-info: var(--bitfun-info, #237f9c); +} + +html, body { width: 100%; height: 100%; margin: 0; } +html { background: var(--lx-bg); } +body { + overflow: hidden; + background: var(--lx-bg); + color: var(--lx-text); + font: 13px/1.45 var(--lx-font); + letter-spacing: 0; +} + +button, input, select, textarea { color: inherit; font: inherit; letter-spacing: 0; } +button { cursor: pointer; } +button:disabled { cursor: not-allowed; opacity: .48; } +[hidden] { display: none !important; } + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.skip-link { + position: fixed; + top: 6px; + left: 8px; + z-index: 100; + transform: translateY(-150%); + padding: 6px 9px; + border-radius: var(--lx-radius); + background: var(--lx-accent); + color: white; + text-decoration: none; +} +.skip-link:focus { transform: translateY(0); } + +:focus-visible { + outline: 2px solid var(--lx-accent); + outline-offset: 2px; +} + +.loopx-shell { + display: flex; + flex-direction: column; + width: 100%; + height: 100dvh; + min-width: 0; +} + +.intake-header { + display: grid; + grid-template-columns: minmax(150px, 190px) minmax(300px, 760px) 32px; + align-items: center; + justify-content: center; + gap: 12px; + min-height: 64px; + padding: 10px 14px; + border-bottom: 1px solid var(--lx-border); + background: var(--lx-surface); +} + +.product-mark { display: flex; align-items: center; gap: 9px; min-width: 0; } +.product-mark__glyph { + display: grid; + place-items: center; + flex: 0 0 auto; + width: 30px; + height: 30px; + border: 1px solid color-mix(in srgb, var(--lx-info) 55%, var(--lx-border)); + border-radius: 7px; + background: color-mix(in srgb, var(--lx-info) 12%, transparent); + color: var(--lx-info); + font: 700 10px/1 var(--lx-mono); +} +.product-mark__copy { display: flex; flex-direction: column; min-width: 0; } +.product-mark__copy strong { font-size: 14px; } +.product-mark__copy span { + overflow: hidden; + color: var(--lx-text-muted); + font-size: 10.5px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.intake-form { + display: grid; + grid-template-columns: 18px minmax(100px, 1fr) auto 34px; + align-items: center; + gap: 7px; + min-width: 0; + height: 40px; + padding: 3px 4px 3px 11px; + border: 1px solid var(--lx-border-strong); + border-radius: var(--lx-radius); + background: var(--lx-elevated); +} +.intake-form:focus-within { border-color: var(--lx-accent); } +.field-icon { display: grid; place-items: center; color: var(--lx-text-muted); } +.intake-form input { + min-width: 0; + height: 32px; + padding: 0; + border: 0; + outline: 0; + background: transparent; +} +.intake-form input::placeholder, .search-field input::placeholder, textarea::placeholder { color: var(--lx-text-muted); } +.model-field { + display: inline-flex; + align-items: center; + min-width: 0; + height: 28px; + padding: 0 8px; + border: 1px solid var(--lx-border); + border-radius: 6px; + background: var(--lx-element); + color: var(--lx-text-soft); + font-size: 11px; +} +.model-field__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.model-field__select { + min-width: 0; + max-width: 150px; + border: 0; + background: transparent; + color: var(--lx-text); + font: inherit; + font-size: 11px; + cursor: pointer; + outline: none; +} + +.icon-button { + display: inline-grid; + place-items: center; + flex: 0 0 auto; + width: 32px; + height: 32px; + padding: 0; + border: 1px solid var(--lx-border); + border-radius: 7px; + background: transparent; + color: var(--lx-text-soft); +} +.icon-button:hover { border-color: var(--lx-border-strong); background: var(--lx-hover); color: var(--lx-text); } +.icon-button--small { width: 28px; height: 28px; } +.icon-button--accent { border-color: var(--lx-accent); background: var(--lx-accent); color: white; } +.icon-button--accent:hover { border-color: var(--lx-accent-hover); background: var(--lx-accent-hover); color: white; } +.is-spinning svg { animation: spin .9s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } + +.notice, .unsupported-banner { + flex: 0 0 auto; + margin: 8px 12px 0; + padding: 8px 10px; + border: 1px solid var(--lx-border); + border-radius: var(--lx-radius); + background: var(--lx-element); + color: var(--lx-text-soft); + font-size: 12px; +} +.notice[data-tone="error"] { border-color: color-mix(in srgb, var(--lx-error) 45%, var(--lx-border)); color: var(--lx-error); } +.notice[data-tone="success"] { border-color: color-mix(in srgb, var(--lx-success) 45%, var(--lx-border)); color: var(--lx-success); } +.unsupported-banner { + display: flex; + align-items: flex-start; + gap: 10px; + border-color: color-mix(in srgb, var(--lx-error) 42%, var(--lx-border)); + background: color-mix(in srgb, var(--lx-error) 8%, var(--lx-bg)); +} +.unsupported-banner p { margin: 2px 0 0; color: var(--lx-text-soft); } +.status-icon { + display: grid; + place-items: center; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--lx-error); + color: white; + font-weight: 700; +} + +.environment-panel { + flex: 0 0 auto; + border-bottom: 1px solid var(--lx-border); + background: var(--lx-muted-surface); +} +.environment-panel > summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 34px; + padding: 5px 14px; + color: var(--lx-text-soft); + cursor: pointer; + list-style: none; +} +.environment-panel > summary::-webkit-details-marker { display: none; } +.environment-panel > summary:hover { background: var(--lx-hover); } +.summary-title { display: flex; align-items: center; gap: 7px; } +.summary-title strong { color: var(--lx-text); font-size: 11px; } +.summary-meta { color: var(--lx-text-muted); font-size: 10.5px; } +.status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--lx-text-muted); } +.status-dot[data-status="available"], .status-dot[data-status="ready"], .status-dot[data-status="completed"] { background: var(--lx-success); } +.status-dot[data-status="checking"], .status-dot[data-status="running"], .status-dot[data-status="queued"] { background: var(--lx-info); } +.status-dot[data-status="degraded"], .status-dot[data-status="waiting_for_user"], .status-dot[data-status="retry_wait"] { background: var(--lx-warning); } +.status-dot[data-status="unavailable"], .status-dot[data-status="blocked"], .status-dot[data-status="failed"] { background: var(--lx-error); } +.environment-body { + display: grid; + grid-template-columns: 1fr 1fr auto; + align-items: end; + gap: 18px; + padding: 8px 14px 12px; + border-top: 1px solid var(--lx-border); +} +.section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 6px; } +.section-heading h2, .section-heading h3 { margin: 0; font-size: 11px; } +.section-heading > span { color: var(--lx-text-muted); font-size: 10px; } +.environment-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; } +.environment-fact { + min-width: 0; + padding: 6px 8px; + border-left: 2px solid var(--lx-border-strong); + background: var(--lx-element); +} +.environment-fact[data-status="available"] { border-left-color: var(--lx-success); } +.environment-fact[data-status="degraded"] { border-left-color: var(--lx-warning); } +.environment-fact[data-status="unavailable"] { border-left-color: var(--lx-error); } +.environment-fact__title { display: flex; align-items: center; justify-content: space-between; gap: 5px; } +.environment-fact__title strong { overflow: hidden; font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; } +.environment-fact__title span { color: var(--lx-text-muted); font: 9.5px/1.2 var(--lx-mono); } +.environment-fact p { margin: 3px 0 0; overflow: hidden; color: var(--lx-text-muted); font-size: 9.5px; text-overflow: ellipsis; white-space: nowrap; } + +.text-button, .primary-button, .danger-button { + min-height: 30px; + padding: 5px 10px; + border: 1px solid var(--lx-border); + border-radius: 7px; + background: transparent; + color: var(--lx-text-soft); +} +.text-button:hover { background: var(--lx-hover); color: var(--lx-text); } +.primary-button { border-color: var(--lx-accent); background: var(--lx-accent); color: white; } +.primary-button:hover { border-color: var(--lx-accent-hover); background: var(--lx-accent-hover); } +.danger-button { border-color: color-mix(in srgb, var(--lx-error) 45%, var(--lx-border)); color: var(--lx-error); } +.danger-button:hover { background: color-mix(in srgb, var(--lx-error) 9%, transparent); } + +.workbench { display: grid; grid-template-columns: minmax(230px, 286px) minmax(0, 1fr); flex: 1 1 auto; min-height: 0; } +.workbench.tasks-collapsed { grid-template-columns: 52px minmax(0, 1fr); } +.task-rail { + display: flex; + flex-direction: column; + min-width: 0; + border-right: 1px solid var(--lx-border); + background: var(--lx-surface); +} +.task-rail__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 43px; + padding: 7px 9px 7px 12px; + border-bottom: 1px solid var(--lx-border); +} +.task-rail__header > div { display: flex; align-items: center; gap: 7px; min-width: 0; } +.task-rail__header h2 { margin: 0; font-size: 12px; } +.count-badge { + min-width: 18px; + padding: 0 5px; + border-radius: 7px; + background: var(--lx-element); + color: var(--lx-text-muted); + font: 10px/18px var(--lx-mono); + text-align: center; +} +.task-list { min-height: 0; padding: 6px; overflow-y: auto; } +.task-item { + display: grid; + grid-template-columns: 22px minmax(0, 1fr) auto; + align-items: center; + gap: 7px; + width: 100%; + min-height: 46px; + margin: 0 0 3px; + padding: 6px 7px; + border: 1px solid transparent; + border-radius: var(--lx-radius); + background: transparent; + color: var(--lx-text-soft); + text-align: left; +} +.task-item:hover { background: var(--lx-hover); } +.task-item.is-selected { border-color: var(--lx-border); background: var(--lx-element); color: var(--lx-text); } +.task-item__icon { + display: grid; + place-items: center; + width: 22px; + height: 22px; + border-radius: 6px; + background: var(--lx-element); + color: var(--lx-text-muted); + font: 700 10px/1 var(--lx-mono); +} +.task-item__main { display: flex; flex-direction: column; min-width: 0; } +.task-item__main strong { overflow: hidden; font-size: 11.5px; text-overflow: ellipsis; white-space: nowrap; } +.task-item__main small { overflow: hidden; color: var(--lx-text-muted); font-size: 9.5px; text-overflow: ellipsis; white-space: nowrap; } +.task-item__state { align-self: start; margin-top: 2px; } +.task-empty { display: grid; place-items: center; gap: 4px; margin: auto; color: var(--lx-text-muted); text-align: center; } +.task-empty span { font: 22px/1 var(--lx-mono); } +.task-empty p { margin: 0; font-size: 11px; } +.task-rail.is-collapsed { width: 52px; min-width: 52px; } +.task-rail.is-collapsed .task-rail__header { justify-content: center; padding-inline: 5px; } +.task-rail.is-collapsed .task-rail__header > div, .task-rail.is-collapsed .task-item__main, +.task-rail.is-collapsed .task-item__state, .task-rail.is-collapsed .task-empty { display: none; } +.task-rail.is-collapsed .task-item { grid-template-columns: 1fr; min-height: 38px; padding: 6px; } +.task-rail.is-collapsed .task-item__icon { margin: auto; } +.task-rail.is-collapsed #collapse-tasks svg { transform: rotate(180deg); } + +.log-workspace { position: relative; display: flex; flex-direction: column; min-width: 0; min-height: 0; background: var(--lx-bg); } +.log-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + min-height: 54px; + padding: 7px 12px 7px 14px; + border-bottom: 1px solid var(--lx-border); +} +.log-title-block { min-width: 130px; } +.log-title-row { display: flex; align-items: center; gap: 7px; } +.log-title-row h1 { margin: 0; font-size: 13px; } +.log-title-block p { margin: 2px 0 0; overflow: hidden; color: var(--lx-text-muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } +.state-pill, .event-level { + display: inline-flex; + align-items: center; + min-height: 18px; + padding: 1px 6px; + border-radius: 7px; + background: var(--lx-element); + color: var(--lx-text-soft); + font-size: 9.5px; + white-space: nowrap; +} +.state-pill[data-state="running"], .state-pill[data-state="completed"] { color: var(--lx-success); } +.state-pill[data-state="waiting_for_user"], .state-pill[data-state="retry_wait"] { color: var(--lx-warning); } +.state-pill[data-state="failed"] { color: var(--lx-error); } +.log-controls { display: flex; align-items: center; justify-content: flex-end; gap: 6px; min-width: 0; } +.segmented { display: flex; padding: 2px; border: 1px solid var(--lx-border); border-radius: 7px; background: var(--lx-muted-surface); } +.segmented button { + min-height: 24px; + padding: 2px 7px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--lx-text-muted); + font-size: 10px; +} +.segmented button.is-active { background: var(--lx-elevated); color: var(--lx-text); } +.search-field { + display: flex; + align-items: center; + gap: 5px; + width: clamp(118px, 20vw, 230px); + height: 28px; + padding: 0 7px; + border: 1px solid var(--lx-border); + border-radius: 7px; + color: var(--lx-text-muted); +} +.search-field:focus-within { border-color: var(--lx-accent); } +.search-field input { min-width: 0; width: 100%; border: 0; outline: 0; background: transparent; font-size: 10.5px; } +.toggle-field { display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; color: var(--lx-text-muted); font-size: 10px; } +.toggle-field input { accent-color: var(--lx-error); } + +.liveness-panel { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + min-height: 40px; + padding: 5px 12px 5px 14px; + border-bottom: 1px solid var(--lx-border); + background: var(--lx-muted-surface); + font-size: 10.5px; +} +.liveness-primary, .liveness-details, .task-actions { display: flex; align-items: center; gap: 7px; min-width: 0; } +.liveness-primary strong { white-space: nowrap; } +.liveness-primary span, .liveness-details { color: var(--lx-text-muted); } +.liveness-details span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.error-text { color: var(--lx-error) !important; } +.task-actions { justify-content: flex-end; } +.task-actions button { min-height: 27px; padding: 3px 8px; font-size: 10px; } + +.log-scroll { position: relative; min-height: 0; flex: 1 1 auto; overflow: auto; scrollbar-gutter: stable; } +.log-list { min-height: 100%; margin: 0; padding: 5px 0 24px; list-style: none; } +.log-row { + display: grid; + grid-template-columns: 64px 58px 72px minmax(0, 1fr); + gap: 8px; + min-height: 29px; + padding: 5px 14px; + border-bottom: 1px solid color-mix(in srgb, var(--lx-border) 65%, transparent); + font: 10.5px/1.45 var(--lx-mono); +} +.log-row:hover { background: var(--lx-hover); } +.log-row[data-level="warning"] { background: color-mix(in srgb, var(--lx-warning) 4%, transparent); } +.log-row[data-level="error"] { background: color-mix(in srgb, var(--lx-error) 6%, transparent); } +.log-time { color: var(--lx-text-muted); } +.event-level { justify-content: center; min-height: 17px; padding: 0 4px; font: 9px/1.2 var(--lx-font); } +.event-level[data-level="warning"] { color: var(--lx-warning); } +.event-level[data-level="error"] { color: var(--lx-error); } +.event-level[data-level="debug"], .event-level[data-level="trace"] { color: var(--lx-text-muted); } +.log-source { overflow: hidden; color: var(--lx-info); text-overflow: ellipsis; white-space: nowrap; } +.log-content { min-width: 0; } +.log-message { color: var(--lx-text-soft); overflow-wrap: anywhere; white-space: pre-wrap; } +.log-row[data-level="error"] .log-message { color: var(--lx-error); } +.log-meta { display: flex; flex-wrap: wrap; gap: 4px 8px; margin-top: 2px; color: var(--lx-text-muted); font-size: 9px; } +.log-details { margin-top: 4px; } +.log-details summary { color: var(--lx-text-muted); cursor: pointer; font-family: var(--lx-font); } +.log-details dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 2px 8px; margin: 4px 0 0; padding: 6px; background: var(--lx-element); } +.log-details dt { color: var(--lx-text-muted); } +.log-details dd { min-width: 0; margin: 0; overflow-wrap: anywhere; } +.log-empty { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 7px; color: var(--lx-text-muted); } +.log-empty p { margin: 0; font-size: 11px; } +.new-events { + position: absolute; + right: 20px; + bottom: 32px; + min-height: 28px; + padding: 3px 9px; + border: 1px solid var(--lx-accent); + border-radius: 7px; + background: var(--lx-accent); + color: white; + font-size: 10px; +} +.stream-status { + display: flex; + align-items: center; + gap: 12px; + min-height: 24px; + padding: 3px 12px; + border-top: 1px solid var(--lx-border); + color: var(--lx-text-muted); + font: 9.5px/1.2 var(--lx-mono); +} + +.dialog { + width: min(480px, calc(100vw - 28px)); + max-height: min(720px, calc(100dvh - 28px)); + padding: 0; + overflow: hidden; + border: 1px solid var(--lx-border-strong); + border-radius: var(--lx-radius); + background: var(--lx-elevated); + color: var(--lx-text); + box-shadow: 0 18px 56px color-mix(in srgb, var(--lx-bg) 70%, transparent); +} +.dialog--wide { width: min(720px, calc(100vw - 28px)); } +.dialog::backdrop { background: color-mix(in srgb, var(--lx-bg) 72%, transparent); } +.dialog form { display: flex; flex-direction: column; max-height: inherit; } +.dialog__header, .dialog__footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 11px 13px; } +.dialog__header { border-bottom: 1px solid var(--lx-border); } +.dialog__header h2 { margin: 1px 0 0; font-size: 14px; overflow-wrap: anywhere; } +.eyebrow { color: var(--lx-text-muted); font-size: 9.5px; text-transform: uppercase; } +.dialog__body { min-height: 0; padding: 12px 13px; overflow-y: auto; } +.dialog__body > p { margin: 0; color: var(--lx-text-soft); } +.dialog__footer { border-top: 1px solid var(--lx-border); } +.dialog__footer--split > div { display: flex; gap: 6px; } +.preview-facts { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; margin: 0 0 13px; background: var(--lx-border); } +.preview-facts > div { min-width: 0; padding: 7px 8px; background: var(--lx-muted-surface); } +.preview-facts dt { color: var(--lx-text-muted); font-size: 9.5px; } +.preview-facts dd { margin: 2px 0 0; overflow: hidden; color: var(--lx-text-soft); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; } +.candidate-list, .permission-list { display: grid; gap: 4px; margin-bottom: 13px; } +.candidate-item, .permission-item { + display: grid; + grid-template-columns: 18px minmax(0, 1fr) auto; + align-items: start; + gap: 7px; + padding: 7px 8px; + border: 1px solid var(--lx-border); + border-radius: 7px; + color: var(--lx-text-soft); +} +.candidate-item input, .permission-item input { margin: 2px 0 0; accent-color: var(--lx-accent); } +.candidate-copy, .permission-copy { display: flex; flex-direction: column; min-width: 0; } +.candidate-copy strong, .permission-copy strong { font-size: 10.5px; overflow-wrap: anywhere; } +.candidate-copy small, .permission-copy small { color: var(--lx-text-muted); font-size: 9.5px; } +.candidate-state { color: var(--lx-text-muted); font-size: 9.5px; } +.candidate-item[data-state="closed"] .candidate-state, .candidate-item[data-state="merged"] .candidate-state { color: var(--lx-success); } +.permission-item[data-risk="high"] { border-color: color-mix(in srgb, var(--lx-warning) 38%, var(--lx-border)); } +.dialog-warning { margin-top: 5px; padding: 7px 8px; border-left: 2px solid var(--lx-warning); background: color-mix(in srgb, var(--lx-warning) 7%, transparent); color: var(--lx-text-soft); font-size: 10.5px; } +.note-field { display: flex; flex-direction: column; gap: 5px; margin-top: 12px; color: var(--lx-text-soft); font-size: 10.5px; } +.note-field textarea { width: 100%; resize: vertical; padding: 7px; border: 1px solid var(--lx-border); border-radius: 7px; outline: 0; background: var(--lx-muted-surface); } +.note-field textarea:focus { border-color: var(--lx-accent); } + +@media (max-width: 900px) { + .intake-header { grid-template-columns: minmax(110px, 150px) minmax(260px, 1fr) 32px; } + .environment-body { grid-template-columns: 1fr; align-items: stretch; } + .environment-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .log-header { align-items: flex-start; flex-direction: column; } + .log-controls { width: 100%; justify-content: flex-start; flex-wrap: wrap; } + .search-field { flex: 1 1 150px; width: auto; } + .liveness-panel { grid-template-columns: 1fr auto; } + .liveness-details { grid-column: 1 / -1; grid-row: 2; } +} + +@media (max-width: 680px) { + .intake-header { grid-template-columns: minmax(0, 1fr) 32px; gap: 7px; } + .product-mark { grid-column: 1; } + .intake-form { grid-column: 1 / -1; grid-row: 2; } + #sync-button { grid-column: 2; grid-row: 1; } + .environment-grid { grid-template-columns: 1fr; } + .workbench { grid-template-columns: 1fr; grid-template-rows: minmax(92px, 30dvh) minmax(0, 1fr); } + .workbench.tasks-collapsed { grid-template-columns: 1fr; grid-template-rows: 44px minmax(0, 1fr); } + .task-rail { border-right: 0; border-bottom: 1px solid var(--lx-border); } + .task-rail.is-collapsed { width: auto; min-width: 0; height: 44px; } + .task-rail.is-collapsed .task-list, .task-rail.is-collapsed .task-empty { display: none; } + .task-rail.is-collapsed .task-rail__header { justify-content: space-between; } + .task-rail.is-collapsed .task-rail__header > div { display: flex; } + .log-row { grid-template-columns: 54px 52px minmax(0, 1fr); gap: 6px; padding-inline: 8px; } + .log-source { display: none; } + .toggle-field { order: 4; } + .preview-facts { grid-template-columns: 1fr; } +} + +@media (max-width: 430px) { + .model-field { max-width: 88px; } + .segmented { width: 100%; } + .segmented button { flex: 1; } + .search-field { flex-basis: calc(100% - 72px); } + .liveness-panel { grid-template-columns: 1fr; } + .task-actions { justify-content: flex-start; flex-wrap: wrap; } + .liveness-details { grid-column: 1; grid-row: auto; } + .dialog__footer { align-items: stretch; flex-direction: column-reverse; } + .dialog__footer > button { width: 100%; } + .dialog__footer--split > div { display: grid; grid-template-columns: 1fr 1fr; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; } +} diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/test/thin-client-runtime.test.mjs b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/test/thin-client-runtime.test.mjs new file mode 100644 index 0000000000..aac735057b --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/test/thin-client-runtime.test.mjs @@ -0,0 +1,377 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import test from 'node:test'; + +const requireFromWebUi = createRequire( + new URL('../../../../../../../../../web-ui/package.json', import.meta.url), +); +const { JSDOM, VirtualConsole } = requireFromWebUi('jsdom'); + +const ASSET_ROOT = new URL('../', import.meta.url); + +async function readAsset(name) { + return readFile(new URL(name, ASSET_ROOT), 'utf8'); +} + +async function waitFor(predicate, description) { + const deadline = Date.now() + 2000; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + assert.fail(`timed out waiting for ${description}`); +} + +function plain(value) { + return JSON.parse(JSON.stringify(value)); +} + +function installBrowserShims(window) { + window.requestAnimationFrame = (callback) => { + queueMicrotask(() => callback(Date.now())); + return 1; + }; + window.cancelAnimationFrame = () => {}; + window.setInterval = () => 1; + window.clearInterval = () => {}; + window.document.querySelectorAll('dialog').forEach((dialog) => { + dialog.showModal = () => { + dialog.open = true; + dialog.setAttribute('open', ''); + }; + dialog.close = () => { + dialog.open = false; + dialog.removeAttribute('open'); + }; + }); +} + +function issueKey(number = 2382) { + return { + repository: { + host: 'github.com', + owner: 'GCWing', + repository: 'BitFun', + }, + kind: 'issue', + number, + }; +} + +function taskSnapshot(now) { + return { + taskId: 'task-2382-1', + batchId: 'batch-1', + identity: { item: issueKey(), attempt: 1 }, + generation: 2, + revision: 7, + goalId: 'goal-2382', + agentId: 'agent-loopx', + state: 'running', + phase: 'agent_running', + workspacePath: 'D:\\BitFun-worktrees\\issue-2382', + modelId: 'primary', + grantedScopes: [ + 'workspace_read', + 'workspace_write', + 'git_local', + 'github_read', + 'agent_execution', + ], + currentTurnId: 'turn-4', + currentTool: 'cargo test', + lastOutputAt: now - 5000, + deadlineAt: now + 120000, + retryAt: null, + error: null, + settlement: {}, + createdAt: now - 60000, + updatedAt: now - 5000, + }; +} + +function controllerSnapshot(now, task) { + const available = (version, detail) => ({ + status: 'available', + version, + detail, + checkedAt: now - 1000, + }); + return { + schemaVersion: 1, + streamId: 'stream-runtime-1', + cursor: 2, + revision: 11, + executionDomain: 'local_desktop', + executionSupport: 'supported', + unsupportedReason: null, + environment: { + revision: 4, + status: 'degraded', + core: { + sidecar: available('0.2.13', 'Pinned adapter ready'), + gitWorktree: available('2.51.0', 'Worktree service ready'), + agentModel: available('primary', 'Model available'), + }, + optional: { + pythonFallback: { + status: 'unavailable', + detail: 'Optional fallback is not installed', + checkedAt: now - 1000, + }, + openViking: { status: 'unknown', detail: 'Not configured' }, + githubAuth: available('gh', 'Authenticated'), + }, + checkedAt: now - 1000, + }, + tasks: [task], + generatedAt: now, + }; +} + +function historyEvents(now) { + return [ + { + streamId: 'stream-runtime-1', + cursor: 1, + taskId: 'task-2382-1', + generation: 2, + revision: 6, + kind: 'task_created', + level: 'info', + source: 'controller', + phase: 'queued', + message: 'Task created after intake confirmation', + important: true, + details: { attempt: '1' }, + occurredAt: now - 10000, + }, + { + streamId: 'stream-runtime-1', + cursor: 2, + taskId: 'task-2382-1', + generation: 2, + revision: 7, + kind: 'phase_changed', + level: 'info', + source: 'agent', + phase: 'agent_running', + message: 'Agent turn is running in the bound worktree', + important: true, + toolName: 'cargo test', + deadlineAt: now + 120000, + details: {}, + occurredAt: now - 5000, + }, + ]; +} + +function intakePreview(now) { + return { + fingerprint: 'sha256:runtime-preview', + target: { targetType: 'item', item: issueKey() }, + repository: issueKey().repository, + workspace: { + disposition: 'existing_worktree', + path: 'D:\\BitFun-worktrees\\issue-2382', + repositoryVerified: true, + }, + candidates: [{ + key: issueKey(), + url: 'https://github.com/GCWing/BitFun/issues/2382', + title: 'Keep LoopX tasks alive outside the MiniApp tab', + state: 'open', + fromRepository: false, + hasImages: false, + defaultSelected: true, + }], + truncated: false, + model: { modelId: 'primary', available: true, supportsImages: true }, + permissionScopes: [ + 'workspace_read', + 'workspace_write', + 'agent_execution', + 'publish', + ], + resolvedAt: now, + expiresAt: now + 60000, + }; +} + +test('thin client boots from host state and completes the confirmed intake flow', async () => { + const [html, ui] = await Promise.all([ + readAsset('index.html'), + readAsset('ui.js'), + ]); + const virtualConsole = new VirtualConsole(); + const jsdomErrors = []; + virtualConsole.on('jsdomError', (error) => jsdomErrors.push(error)); + const dom = new JSDOM(html, { + url: 'https://miniapp.invalid/builtin-bitfun-loopx/', + runScripts: 'outside-only', + pretendToBeVisual: true, + virtualConsole, + }); + const { window } = dom; + installBrowserShims(window); + + const now = Date.now(); + const task = taskSnapshot(now); + const snapshot = controllerSnapshot(now, task); + const events = historyEvents(now); + const preview = intakePreview(now); + const callOrder = []; + const attachRequests = []; + const eventRequests = []; + const resolveRequests = []; + const createRequests = []; + const forbiddenAccesses = []; + let eventListener = null; + + const loopx = { + onEvent(listener) { + callOrder.push('onEvent'); + eventListener = listener; + }, + offEvent(listener) { + assert.equal(listener, eventListener); + }, + async attach(request) { + callOrder.push('attach'); + attachRequests.push(request); + return { snapshot: structuredClone(snapshot) }; + }, + async eventsSince(request) { + eventRequests.push(request); + return { + status: 'current', + streamId: snapshot.streamId, + events: structuredClone(events), + nextCursor: snapshot.cursor, + hasMore: false, + }; + }, + async resolveIntake(request) { + resolveRequests.push(request); + return { preview: structuredClone(preview) }; + }, + async createTask(request) { + createRequests.push(request); + return { + outcomes: [{ + item: issueKey(), + kind: 'opened_existing', + taskId: task.taskId, + attempt: 1, + }], + snapshotRevision: snapshot.revision, + }; + }, + async action() { + assert.fail('the intake smoke test must not dispatch task actions'); + }, + }; + const appTarget = { + locale: 'en-US', + loopx, + onLocaleChange() {}, + onActivate() {}, + }; + window.app = new Proxy(appTarget, { + get(target, property, receiver) { + if (['agent', 'call', 'worker'].includes(String(property))) { + forbiddenAccesses.push(String(property)); + } + return Reflect.get(target, property, receiver); + }, + }); + + try { + window.eval(ui); + await waitFor(() => window.document.querySelectorAll('#log-list .log-row').length === 2, 'initial rendering'); + + assert.deepEqual(callOrder.slice(0, 2), ['onEvent', 'attach']); + assert.deepEqual(plain(attachRequests[0]), {}); + assert.deepEqual(plain(eventRequests), [{ + streamId: snapshot.streamId, + afterCursor: 0, + limit: 250, + }]); + assert.equal(window.document.querySelector('#loopx-app').getAttribute('aria-busy'), 'false'); + assert.equal(window.document.querySelector('#task-count').textContent, '1'); + assert.match(window.document.querySelector('#task-items').textContent, /GCWing\/BitFun Issue #2382/); + assert.equal(window.document.querySelector('#environment-status').textContent, 'Degraded'); + assert.match(window.document.querySelector('#core-environment-list').textContent, /0\.2\.13/); + assert.match(window.document.querySelector('#log-list').textContent, /Agent turn is running/); + + assert.equal(typeof eventListener, 'function'); + eventListener({ + event: { + streamId: snapshot.streamId, + cursor: 3, + taskId: task.taskId, + generation: 2, + revision: 7, + kind: 'log', + level: 'warning', + source: 'git', + phase: 'agent_running', + message: 'Validation produced one warning', + important: false, + details: {}, + occurredAt: now, + }, + }); + await waitFor( + () => /Validation produced one warning/.test(window.document.querySelector('#log-list').textContent), + 'stream event rendering', + ); + + const input = window.document.querySelector('#intake-input'); + input.value = 'https://github.com/GCWing/BitFun/issues/2382'; + window.document.querySelector('#intake-form').dispatchEvent(new window.Event('submit', { + bubbles: true, + cancelable: true, + })); + await waitFor(() => window.document.querySelector('#intake-dialog').open, 'intake dialog'); + + assert.deepEqual(plain(resolveRequests), [{ + input: 'https://github.com/GCWing/BitFun/issues/2382', + modelId: 'auto', + }]); + assert.equal(window.document.querySelector('#preview-repository').textContent, 'GCWing/BitFun'); + assert.equal( + window.document.querySelector('#preview-workspace').textContent, + 'D:\\BitFun-worktrees\\issue-2382', + ); + assert.match(window.document.querySelector('#candidate-list').textContent, /Keep LoopX tasks alive/); + assert.equal(window.document.querySelector('input[name="candidate"]').checked, true); + assert.equal( + window.document.querySelector('input[name="permission"][value="publish"]').checked, + false, + ); + + window.document.querySelector('#intake-confirm-form').dispatchEvent(new window.Event('submit', { + bubbles: true, + cancelable: true, + })); + await waitFor(() => createRequests.length === 1 && !window.document.querySelector('#intake-dialog').open, 'task creation'); + + assert.equal(createRequests[0].previewFingerprint, preview.fingerprint); + assert.deepEqual(plain(createRequests[0].selectedItems), [issueKey()]); + assert.deepEqual(plain(createRequests[0].grantedScopes), [ + 'workspace_read', + 'workspace_write', + 'agent_execution', + ]); + assert.equal(createRequests[0].modelId, 'primary'); + assert.equal(createRequests[0].retryTerminal, false); + assert.ok(createRequests[0].clientRequestId); + assert.match(window.document.querySelector('#notice').textContent, /existing task.*duplicate/i); + assert.deepEqual(forbiddenAccesses, []); + assert.deepEqual(jsdomErrors, []); + } finally { + window.close(); + } +}); diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/test/thin-client-source-contract.test.mjs b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/test/thin-client-source-contract.test.mjs new file mode 100644 index 0000000000..de3399a7a7 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/test/thin-client-source-contract.test.mjs @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const ASSET_ROOT = new URL('../', import.meta.url); + +async function readAsset(name) { + return readFile(new URL(name, ASSET_ROOT), 'utf8'); +} + +function openingTags(source, tagName) { + return [...source.matchAll(new RegExp(`<${tagName}\\b[^>]*>`, 'gi'))] + .map((match) => match[0]); +} + +function tagWithMarker(source, tagName, marker, description) { + const tag = openingTags(source, tagName).find((candidate) => marker.test(candidate)); + assert.ok(tag, `missing ${description}`); + return tag; +} + +function hasAccessibleName(tag) { + return /\baria-label(?:ledby)?\s*=\s*(['"])[^'"]+\1/i.test(tag); +} + +function executableWorkerSource(source) { + return source + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^\s*\/\/.*$/gm, '') + .trim(); +} + +test('LoopX UI is a thin client of the host-owned controller', async () => { + const ui = await readAsset('ui.js'); + const requiredMethods = [ + 'attach', + 'resolveIntake', + 'createTask', + 'action', + 'eventsSince', + ]; + + for (const method of requiredMethods) { + assert.ok( + new RegExp(`\\bapp\\.loopx\\.${method}\\s*\\(`).test(ui), + `ui.js must use app.loopx.${method}`, + ); + } + assert.ok( + /\bapp\.loopx\.onEvent\s*\(/.test(ui), + 'ui.js must subscribe to the host loopx:event stream', + ); + + const allowedLoopxMethods = new Set([ + ...requiredMethods, + 'onEvent', + 'offEvent', + ]); + const usedLoopxMethods = [ + ...ui.matchAll(/\bapp\.loopx\.([A-Za-z][A-Za-z0-9_]*)/g), + ].map((match) => match[1]); + assert.ok(usedLoopxMethods.length > 0, 'ui.js must use the LoopX namespace'); + for (const method of usedLoopxMethods) { + assert.ok( + allowedLoopxMethods.has(method), + `ui.js uses unsupported app.loopx method: ${method}`, + ); + } + + const forbiddenSurfaces = [ + [/\bapp\.agent\b/, 'MiniApp-owned Agent lifecycle'], + [/\bapp\.call\s*\(/, 'legacy generic worker calls'], + [/\b(?:app\.)?worker\.call\s*\(/, 'direct worker calls'], + [/\bapp\.(?:fs|shell)\b/, 'direct filesystem or shell APIs'], + [/\b(?:argvPrefix|projectDirs?|srcDir)\b/, 'iframe-controlled CLI or checkout paths'], + [/--registry\b|registry\.json|\bregistry(?:Path|Args)\b/i, 'direct registry CLI access'], + [/heartbeat/i, 'iframe-owned heartbeat scheduling'], + ]; + for (const [pattern, description] of forbiddenSurfaces) { + assert.ok(!pattern.test(ui), `ui.js must not contain ${description}`); + } +}); + +test('LoopX worker remains an execution-free compatibility stub', async () => { + const worker = await readAsset('worker.js'); + + assert.ok( + /compatibility|intentionally empty/i.test(worker), + 'worker.js must document its compatibility-only purpose', + ); + assert.ok( + executableWorkerSource(worker) === 'module.exports = {};', + 'worker.js may only export an empty compatibility module', + ); +}); + +test('LoopX metadata disables the Node worker runtime', async () => { + const meta = JSON.parse(await readAsset('meta.json')); + + assert.equal(meta.permissions?.node?.enabled, false); +}); + +test('LoopX HTML exposes an accessible intake, task rail, and log-first workspace', async () => { + const html = await readAsset('index.html'); + const intakeHeader = tagWithMarker( + html, + 'header', + /\b(?:id|class)\s*=\s*(['"])[^'"]*intake[^'"]*\1/i, + 'intake header landmark', + ); + const intakeForm = tagWithMarker( + html, + 'form', + /\b(?:id|class)\s*=\s*(['"])[^'"]*intake[^'"]*\1/i, + 'intake form', + ); + const taskRail = openingTags(html, 'aside').find((tag) => ( + /\b(?:id|class)\s*=\s*(['"])[^'"]*(?:task|rail)[^'"]*\1/i.test(tag) + )); + const logLandmark = [ + ...openingTags(html, 'main'), + ...openingTags(html, 'section'), + ].find((tag) => ( + /\b(?:id|class)\s*=\s*(['"])[^'"]*log[^'"]*\1/i.test(tag) + )); + const logList = tagWithMarker( + html, + '[A-Za-z][A-Za-z0-9:-]*', + /\bid\s*=\s*(['"])log-list\1/i, + '#log-list', + ); + + assert.ok(intakeHeader, 'the intake controls must be grouped in a header'); + assert.ok(hasAccessibleName(intakeForm), 'the intake form needs an accessible name'); + assert.ok(taskRail, 'missing task rail aside landmark'); + assert.ok(hasAccessibleName(taskRail), 'the task rail needs an accessible name'); + assert.ok(logLandmark, 'missing main log landmark'); + assert.match(logLandmark, /\baria-labelledby\s*=\s*(['"])[^'"]+\1/i); + assert.match(logList, /\brole\s*=\s*(['"])log\1/i); + assert.match(logList, /\baria-live\s*=\s*(['"])off\1/i); + + const labelledBy = logLandmark.match(/\baria-labelledby\s*=\s*(['"])([^'"]+)\1/i)?.[2]; + assert.ok(labelledBy, 'the log landmark must reference its visible title'); + assert.match( + html, + new RegExp(`\\bid\\s*=\\s*(['"])${labelledBy.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\1`, 'i'), + 'the log landmark must reference an existing title element', + ); + + const intakeOffset = html.indexOf(intakeHeader); + const railOffset = html.indexOf(taskRail); + const logOffset = html.indexOf(logLandmark); + assert.ok( + intakeOffset < railOffset && railOffset < logOffset, + 'the document order must be top intake, task rail, then primary log view', + ); +}); diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/ui.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/ui.js new file mode 100644 index 0000000000..4caf92a6e7 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/ui.js @@ -0,0 +1,1595 @@ +'use strict'; + +// LoopX is owned by the BitFun host. This file only projects durable snapshots +// and cursor-addressed events into the MiniApp UI. +const app = window.app; +const byId = (id) => document.getElementById(id); +const MAX_EVENTS = 2000; +const MAX_RENDERED_EVENTS = 600; +const HIGH_RISK_SCOPES = new Set([ + 'publish', + 'public_comment', + 'pull_request', + 'merge', + 'production_action', +]); +const KEY_EVENT_KINDS = new Set([ + 'task_created', + 'state_changed', + 'phase_changed', + 'approval_required', + 'settlement_recorded', + 'environment_changed', + 'operation_cancelled', + 'snapshot_invalidated', +]); +const SNAPSHOT_EVENT_KINDS = new Set([ + 'task_created', + 'state_changed', + 'phase_changed', + 'approval_required', + 'settlement_recorded', + 'environment_changed', + 'operation_cancelled', + 'snapshot_invalidated', +]); + +const COPY = { + 'zh-CN': { + skipToLogs: '跳到日志', + connecting: '正在连接宿主', + connected: '已连接', + reconnecting: '正在重新同步', + connectionFailed: '连接失败', + intakeLabel: 'GitHub Issue、Pull Request 或仓库链接', + intakePlaceholder: '粘贴 GitHub Issue、PR、仓库或 Issues 列表链接', + model: '模型', + modelAuto: '自动模型', + modelPrimaryTag: '主模型', + resolve: '分析链接', + resolving: '正在实时复核链接', + refresh: '刷新状态', + unsupportedTitle: '当前执行位置不支持 LoopX', + unsupportedDefault: 'LoopX 目前只支持本地 Desktop 工作区。此入口不会回退到控制端执行。', + environment: '环境', + coreEnvironment: '核心环境', + optionalEnvironment: '增强能力', + required: '必需', + optional: '可选', + retryEnvironment: '重新检查环境', + tasks: '任务', + collapseTasks: '收起任务栏', + expandTasks: '展开任务栏', + allActivity: '全部活动', + noTasks: '暂无任务', + activity: '运行日志', + allTaskEvents: '所有任务的关键事件', + selectedTaskEvents: '当前任务的事件与运行状态', + keyEvents: '关键事件', + fullLog: '完整日志', + searchLogs: '搜索日志', + errorsOnly: '仅错误', + exportLogs: '导出日志', + noLogs: '暂无运行事件', + newEvents: '查看新事件', + notConnected: '未连接', + streamCurrent: '事件流正常', + streamRecovering: '正在补齐事件', + cursor: '游标', + visible: '可见', + confirmTask: '确认任务', + repository: '仓库', + workspace: '工作区', + workspace_existing_worktree: '使用现有 Worktree', + workspace_new_worktree: '将创建独立 Worktree', + workspace_clone_required: '将克隆并创建 Worktree', + workspace_unavailable: '工作区不可用', + imageCapability: '图片能力', + supported: '支持', + unsupported: '不支持', + items: 'Issue / PR', + permissions: '本次权限', + explicitGrant: '逐项授权', + cancel: '取消', + close: '关闭', + createTasks: '创建所选任务', + newAttempt: '新尝试', + terminalExists: '已有终态任务', + confirmNewAttempt: '确认新尝试', + approvalGate: '审批门禁', + approvalNote: '审批备注', + approvalNotePlaceholder: '补充批准或拒绝的原因(可选)', + reject: '拒绝', + approve: '批准', + pause: '暂停', + resume: '恢复', + archive: '归档', + restore: '还原', + retry: '重试', + details: '详情', + currentTool: '工具', + turn: 'Turn', + deadline: '截止', + noRecentOutput: '已 {duration} 没有新输出', + lastOutput: '最后输出 {duration} 前', + updated: '更新于 {duration} 前', + justNow: '刚刚', + seconds: '{value} 秒', + minutes: '{value} 分钟', + hours: '{value} 小时', + days: '{value} 天', + deadlinePassed: '已超过截止时间 {duration}', + deadlineRemaining: '剩余 {duration}', + intakeUnavailable: '当前执行位置不支持创建任务。', + bridgeUnavailable: '宿主没有提供受信任的 LoopX 控制器接口。请更新 BitFun 后重试。', + selectAtLeastOne: '至少选择一个仍开放的 Issue 或 PR。', + selectPermissions: '请确认本次任务需要的权限范围。', + previewExpired: '确认信息已失效,请重新分析链接。', + taskCreated: '任务已创建。', + openedExisting: '已打开现有任务,没有重复创建。', + closedNoop: '目标已关闭或合并,无需创建任务。', + liveVerification: '目标需要再次在线复核,暂未创建任务。', + retryRequired: '该目标已有终态任务。只有确认后才会创建新的 attempt。', + actionApplied: '操作已应用。', + actionDuplicate: '该操作已经应用,无需重复执行。', + revisionConflict: '任务状态已经变化,已刷新到最新版本。', + actionRejected: '宿主拒绝了该操作。', + environmentRetryQueued: '环境检查已重新启动。', + logsExported: '日志已导出。', + noGate: '没有找到可回答的审批门禁,请刷新任务状态。', + approvalNeeded: '任务正在等待远程可回答的审批。', + coreBlocked: '核心环境未就绪,任务不会静默启动。', + optionalDegraded: '增强能力不可用,核心修复流程仍可继续。', + truncatedCandidates: '候选项已截断,请缩小仓库范围后重新分析。', + imageWarning: '所选内容包含图片,但当前模型不支持图片输入。', + modelUnavailable: '当前模型不可用,请返回并选择其他模型。', + workspaceUnavailable: '宿主无法为该仓库准备受信任的 Worktree。', + resolvedItem: '已处理', + openItem: '开放', + fromRepository: '仓库候选', + attempt: '尝试 {value}', + taskNumber: '任务 {value}', + eventCount: '{value} 条事件', + allSources: '全部来源', + sidecar: 'LoopX sidecar', + gitWorktree: 'Git / Worktree', + agentModel: 'Agent 模型', + pythonFallback: 'Python 备用', + openViking: 'OpenViking', + githubAuth: 'GitHub 登录', + status_unknown: '未知', + status_checking: '检查中', + status_available: '可用', + status_degraded: '已降级', + status_unavailable: '不可用', + status_ready: '就绪', + status_blocked: '阻塞', + state_preparing: '准备中', + state_queued: '排队中', + state_running: '运行中', + state_waiting_for_user: '等待审批', + state_retry_wait: '等待重试', + state_cancelling: '正在停止', + state_stopped: '已暂停', + state_recovery_required: '需要恢复', + state_completed: '已完成', + state_failed: '失败', + state_archived: '已归档', + phase_unknown: '等待宿主状态', + phase_validating_environment: '验证环境', + phase_resolving_intake: '复核输入', + phase_preparing_workspace: '准备 Worktree', + phase_creating_goal: '创建 Goal', + phase_queued: '等待调度', + phase_inspecting_goal: '检查 Goal', + phase_building_turn: '构建 Turn', + phase_starting_agent: '启动 Agent', + phase_agent_running: 'Agent 执行中', + phase_validating_progress: '验证持久进度', + phase_settling_turn: '结算 Turn', + phase_waiting_for_approval: '等待审批', + phase_retry_backoff: '重试退避', + phase_cancelling: '正在取消', + phase_recovering: '恢复并对账', + phase_finished: '流程结束', + scope_workspace_read: '读取工作区', + scope_workspace_write: '修改工作区', + scope_git_local: '本地 Git 操作', + scope_github_read: '读取 GitHub', + scope_agent_execution: '运行 Agent', + scope_publish: '发布变更', + scope_public_comment: '公开评论', + scope_pull_request: '创建 Pull Request', + scope_merge: '合并 Pull Request', + scope_production_action: '生产环境操作', + scopeHighRisk: '需要单独确认的外部副作用', + scopeStandard: '本次修复所需能力', + }, + 'en-US': { + skipToLogs: 'Skip to logs', + connecting: 'Connecting to host', + connected: 'Connected', + reconnecting: 'Resynchronizing', + connectionFailed: 'Connection failed', + intakeLabel: 'GitHub issue, pull request, or repository URL', + intakePlaceholder: 'Paste a GitHub issue, PR, repository, or issues-list URL', + model: 'Model', + modelAuto: 'Automatic model', + modelPrimaryTag: 'Primary', + resolve: 'Analyze URL', + resolving: 'Verifying URL against the live source', + refresh: 'Refresh status', + unsupportedTitle: 'LoopX is unavailable in this execution location', + unsupportedDefault: 'LoopX currently supports local Desktop workspaces only. It will not fall back to the controller machine.', + environment: 'Environment', + coreEnvironment: 'Core environment', + optionalEnvironment: 'Optional capabilities', + required: 'Required', + optional: 'Optional', + retryEnvironment: 'Check environment again', + tasks: 'Tasks', + collapseTasks: 'Collapse task rail', + expandTasks: 'Expand task rail', + allActivity: 'All activity', + noTasks: 'No tasks yet', + activity: 'Run log', + allTaskEvents: 'Important events from every task', + selectedTaskEvents: 'Events and liveness for the selected task', + keyEvents: 'Key events', + fullLog: 'Full log', + searchLogs: 'Search logs', + errorsOnly: 'Errors only', + exportLogs: 'Export logs', + noLogs: 'No run events yet', + newEvents: 'View new events', + notConnected: 'Not connected', + streamCurrent: 'Event stream current', + streamRecovering: 'Recovering events', + cursor: 'cursor', + visible: 'visible', + confirmTask: 'Confirm task', + repository: 'Repository', + workspace: 'Workspace', + workspace_existing_worktree: 'Use existing worktree', + workspace_new_worktree: 'Create an isolated worktree', + workspace_clone_required: 'Clone and create a worktree', + workspace_unavailable: 'Workspace unavailable', + imageCapability: 'Image support', + supported: 'Supported', + unsupported: 'Unsupported', + items: 'Issue / PR', + permissions: 'Permissions for this run', + explicitGrant: 'Explicit grant', + cancel: 'Cancel', + close: 'Close', + createTasks: 'Create selected tasks', + newAttempt: 'New attempt', + terminalExists: 'A terminal task already exists', + confirmNewAttempt: 'Confirm new attempt', + approvalGate: 'Approval gate', + approvalNote: 'Approval note', + approvalNotePlaceholder: 'Optional reason for approving or rejecting', + reject: 'Reject', + approve: 'Approve', + pause: 'Pause', + resume: 'Resume', + archive: 'Archive', + restore: 'Restore', + retry: 'Retry', + details: 'Details', + currentTool: 'tool', + turn: 'Turn', + deadline: 'deadline', + noRecentOutput: 'No new output for {duration}', + lastOutput: 'Last output {duration} ago', + updated: 'Updated {duration} ago', + justNow: 'just now', + seconds: '{value}s', + minutes: '{value}m', + hours: '{value}h', + days: '{value}d', + deadlinePassed: 'Deadline passed by {duration}', + deadlineRemaining: '{duration} remaining', + intakeUnavailable: 'Tasks cannot be created from this execution location.', + bridgeUnavailable: 'The host did not expose the trusted LoopX controller. Update BitFun and try again.', + selectAtLeastOne: 'Select at least one open issue or pull request.', + selectPermissions: 'Confirm the permission scopes required by this run.', + previewExpired: 'This preview is stale. Analyze the URL again.', + taskCreated: 'Task created.', + openedExisting: 'Opened the existing task without creating a duplicate.', + closedNoop: 'The target is closed or merged; no task was created.', + liveVerification: 'The target needs another live verification before a task can be created.', + retryRequired: 'A terminal task exists. Confirm before creating a new attempt.', + actionApplied: 'Action applied.', + actionDuplicate: 'This action was already applied.', + revisionConflict: 'Task state changed. The latest snapshot has been loaded.', + actionRejected: 'The host rejected this action.', + environmentRetryQueued: 'Environment verification restarted.', + logsExported: 'Logs exported.', + noGate: 'No answerable approval gate was found. Refresh the task state.', + approvalNeeded: 'The task is waiting at an approval gate that can be answered remotely.', + coreBlocked: 'The core environment is not ready, so execution will not start silently.', + optionalDegraded: 'Optional capabilities are unavailable; the core fix flow can continue.', + truncatedCandidates: 'The candidate list was truncated. Narrow the repository scope and analyze again.', + imageWarning: 'Selected content contains images, but the current model does not support image input.', + modelUnavailable: 'The selected model is unavailable. Go back and choose another model.', + workspaceUnavailable: 'The host cannot prepare a trusted worktree for this repository.', + resolvedItem: 'Resolved', + openItem: 'Open', + fromRepository: 'Repository candidate', + attempt: 'Attempt {value}', + taskNumber: 'Task {value}', + eventCount: '{value} events', + allSources: 'All sources', + sidecar: 'LoopX sidecar', + gitWorktree: 'Git / Worktree', + agentModel: 'Agent model', + pythonFallback: 'Python fallback', + openViking: 'OpenViking', + githubAuth: 'GitHub sign-in', + status_unknown: 'Unknown', + status_checking: 'Checking', + status_available: 'Available', + status_degraded: 'Degraded', + status_unavailable: 'Unavailable', + status_ready: 'Ready', + status_blocked: 'Blocked', + state_preparing: 'Preparing', + state_queued: 'Queued', + state_running: 'Running', + state_waiting_for_user: 'Awaiting approval', + state_retry_wait: 'Retry wait', + state_cancelling: 'Stopping', + state_stopped: 'Paused', + state_recovery_required: 'Recovery required', + state_completed: 'Completed', + state_failed: 'Failed', + state_archived: 'Archived', + phase_unknown: 'Waiting for host state', + phase_validating_environment: 'Validating environment', + phase_resolving_intake: 'Resolving intake', + phase_preparing_workspace: 'Preparing worktree', + phase_creating_goal: 'Creating goal', + phase_queued: 'Waiting for scheduler', + phase_inspecting_goal: 'Inspecting goal', + phase_building_turn: 'Building turn', + phase_starting_agent: 'Starting agent', + phase_agent_running: 'Agent running', + phase_validating_progress: 'Validating durable progress', + phase_settling_turn: 'Settling turn', + phase_waiting_for_approval: 'Waiting for approval', + phase_retry_backoff: 'Retry backoff', + phase_cancelling: 'Cancelling', + phase_recovering: 'Recovering and reconciling', + phase_finished: 'Finished', + scope_workspace_read: 'Read workspace', + scope_workspace_write: 'Modify workspace', + scope_git_local: 'Local Git operations', + scope_github_read: 'Read GitHub', + scope_agent_execution: 'Run agent', + scope_publish: 'Publish changes', + scope_public_comment: 'Post public comments', + scope_pull_request: 'Create pull requests', + scope_merge: 'Merge pull requests', + scope_production_action: 'Production actions', + scopeHighRisk: 'External side effect requiring separate confirmation', + scopeStandard: 'Capability required for this run', + }, +}; + +const view = { + root: byId('loopx-app'), + connectionLabel: byId('connection-label'), + intakeForm: byId('intake-form'), + intakeInput: byId('intake-input'), + modelSelect: byId('model-select'), + resolveButton: byId('resolve-button'), + syncButton: byId('sync-button'), + notice: byId('notice'), + unsupportedBanner: byId('unsupported-banner'), + unsupportedReason: byId('unsupported-reason'), + environmentPanel: byId('environment-panel'), + environmentDot: byId('environment-dot'), + environmentStatus: byId('environment-status'), + environmentChecked: byId('environment-checked'), + coreEnvironmentList: byId('core-environment-list'), + optionalEnvironmentList: byId('optional-environment-list'), + retryEnvironment: byId('retry-environment'), + taskRail: byId('task-rail'), + collapseTasks: byId('collapse-tasks'), + taskCount: byId('task-count'), + taskItems: byId('task-items'), + taskEmpty: byId('task-empty'), + allActivity: byId('all-activity'), + allActivityMeta: byId('all-activity-meta'), + logTitle: byId('log-title'), + selectedState: byId('selected-state'), + selectedSummary: byId('selected-summary'), + modeKey: byId('mode-key'), + modeFull: byId('mode-full'), + logSearch: byId('log-search'), + errorsOnly: byId('errors-only'), + exportLogs: byId('export-logs'), + livenessPanel: byId('liveness-panel'), + livenessDot: byId('liveness-dot'), + livenessPhase: byId('liveness-phase'), + livenessSince: byId('liveness-since'), + livenessTurn: byId('liveness-turn'), + livenessTool: byId('liveness-tool'), + livenessDeadline: byId('liveness-deadline'), + livenessError: byId('liveness-error'), + taskActions: byId('task-actions'), + logScroll: byId('log-scroll'), + logEmpty: byId('log-empty'), + logList: byId('log-list'), + newEvents: byId('new-events'), + streamState: byId('stream-state'), + streamCursor: byId('stream-cursor'), + visibleLogCount: byId('visible-log-count'), + intakeDialog: byId('intake-dialog'), + intakeConfirmForm: byId('intake-confirm-form'), + intakeDialogTitle: byId('intake-dialog-title'), + previewRepository: byId('preview-repository'), + previewWorkspace: byId('preview-workspace'), + previewModel: byId('preview-model'), + previewImages: byId('preview-images'), + candidateCount: byId('candidate-count'), + candidateList: byId('candidate-list'), + permissionList: byId('permission-list'), + intakeWarning: byId('intake-warning'), + createButton: byId('create-button'), + retryDialog: byId('retry-dialog'), + retryMessage: byId('retry-message'), + retryCancel: byId('retry-cancel'), + retryConfirm: byId('retry-confirm'), + gateDialog: byId('gate-dialog'), + gateForm: byId('gate-form'), + gateTitle: byId('gate-dialog-title'), + gateMessage: byId('gate-message'), + gateNote: byId('gate-note'), + gateCancel: byId('gate-cancel'), + gateReject: byId('gate-reject'), + gateApprove: byId('gate-approve'), +}; + +const state = { + snapshot: null, + events: [], + eventKeys: new Set(), + selectedTaskId: null, + logMode: 'key', + query: '', + errorsOnly: false, + followLogs: true, + preview: null, + pendingCreate: null, + pendingRetry: null, + gate: null, + syncing: false, + syncRequested: false, + gapRecovery: null, + connected: false, + railCollapsed: false, + visibleEvents: [], +}; + +function localeId() { + const raw = app && typeof app.locale === 'string' ? app.locale : 'en-US'; + return raw.startsWith('zh') ? 'zh-CN' : 'en-US'; +} + +function text(key, values) { + const table = COPY[localeId()] || COPY['en-US']; + let output = table[key] || COPY['en-US'][key] || key; + if (values) { + Object.entries(values).forEach(([name, value]) => { + output = output.replace(new RegExp(`\\{${name}\\}`, 'g'), String(value)); + }); + } + return output; +} + +function applyLocale() { + document.documentElement.lang = localeId(); + document.querySelectorAll('[data-i18n]').forEach((element) => { + element.textContent = text(element.dataset.i18n); + }); + document.querySelectorAll('[data-i18n-placeholder]').forEach((element) => { + element.setAttribute('placeholder', text(element.dataset.i18nPlaceholder)); + }); + document.querySelectorAll('[data-i18n-title]').forEach((element) => { + const value = text(element.dataset.i18nTitle); + element.setAttribute('title', value); + if (element.getAttribute('aria-label')) element.setAttribute('aria-label', value); + }); + view.intakeForm.setAttribute('aria-label', text('intakeLabel')); + view.modelSelect.setAttribute('aria-label', text('model')); + renderAll(); +} + +function normalizeTimestamp(value) { + const number = Number(value || 0); + if (!Number.isFinite(number) || number <= 0) return 0; + return number < 100000000000 ? number * 1000 : number; +} + +function durationLabel(milliseconds) { + const seconds = Math.max(0, Math.floor(milliseconds / 1000)); + if (seconds < 8) return text('justNow'); + if (seconds < 60) return text('seconds', { value: seconds }); + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return text('minutes', { value: minutes }); + const hours = Math.floor(minutes / 60); + if (hours < 24) return text('hours', { value: hours }); + return text('days', { value: Math.floor(hours / 24) }); +} + +function relativeLabel(value) { + const timestamp = normalizeTimestamp(value); + if (!timestamp) return '--'; + return durationLabel(Date.now() - timestamp); +} + +function clockLabel(value) { + const timestamp = normalizeTimestamp(value); + if (!timestamp) return '--:--:--'; + const date = new Date(timestamp); + return [date.getHours(), date.getMinutes(), date.getSeconds()] + .map((part) => String(part).padStart(2, '0')) + .join(':'); +} + +function stateLabel(value) { return text(`state_${value || 'recovery_required'}`); } +function phaseLabel(value) { return text(`phase_${value || 'unknown'}`); } +function statusLabel(value) { return text(`status_${value || 'unknown'}`); } +function scopeLabel(value) { return text(`scope_${value}`); } + +function showNotice(message, tone = 'neutral') { + if (!message) { + view.notice.hidden = true; + view.notice.textContent = ''; + view.notice.dataset.tone = ''; + return; + } + view.notice.textContent = message; + view.notice.dataset.tone = tone; + view.notice.hidden = false; +} + +function errorMessage(error) { + if (error instanceof Error) return error.message; + return String(error || 'Unknown error'); +} + +function setButtonBusy(button, busy) { + button.disabled = busy; + button.classList.toggle('is-spinning', busy); +} + +function requestId() { + if (window.crypto && typeof window.crypto.randomUUID === 'function') { + return window.crypto.randomUUID(); + } + return `loopx-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function repositoryLabel(repository) { + if (!repository) return '--'; + return `${repository.owner || '?'}/${repository.repository || '?'}`; +} + +function itemKey(item) { + const repository = item && item.repository ? item.repository : {}; + return `${repository.host || ''}/${repository.owner || ''}/${repository.repository || ''}/${item.kind || ''}/${item.number || 0}`; +} + +function itemLabel(item) { + if (!item) return '--'; + const prefix = item.kind === 'pr' ? 'PR' : 'Issue'; + return `${repositoryLabel(item.repository)} ${prefix} #${item.number}`; +} + +function taskForId(taskId) { + if (!state.snapshot || !Array.isArray(state.snapshot.tasks)) return null; + return state.snapshot.tasks.find((task) => task.taskId === taskId) || null; +} + +function selectedTask() { + return state.selectedTaskId ? taskForId(state.selectedTaskId) : null; +} + +function snapshotSupported() { + return state.snapshot && state.snapshot.executionSupport === 'supported'; +} + +function validEvent(event) { + return event + && typeof event === 'object' + && typeof event.streamId === 'string' + && Number.isSafeInteger(event.cursor) + && event.cursor >= 0; +} + +function addEvent(event) { + if (!validEvent(event)) return false; + const key = `${event.streamId}:${event.cursor}`; + if (state.eventKeys.has(key)) return false; + state.eventKeys.add(key); + state.events.push(event); + state.events.sort((left, right) => left.cursor - right.cursor); + while (state.events.length > MAX_EVENTS) { + const removed = state.events.shift(); + state.eventKeys.delete(`${removed.streamId}:${removed.cursor}`); + } + return true; +} + +function replaceStreamEvents(streamId) { + state.events = state.events.filter((event) => event.streamId === streamId); + state.eventKeys = new Set(state.events.map((event) => `${event.streamId}:${event.cursor}`)); +} + +async function replayEvents(streamId, afterCursor, historical = false) { + let cursor = afterCursor; + let pageCount = 0; + let changed = false; + view.streamState.textContent = text('streamRecovering'); + while (pageCount < 30) { + pageCount += 1; + const page = await app.loopx.eventsSince({ + streamId, + afterCursor: cursor, + limit: 250, + }); + if (!page || page.status === 'snapshot_required' || page.streamId !== streamId) { + return { snapshotRequired: true, changed }; + } + (page.events || []).forEach((event) => { + changed = addEvent(event) || changed; + }); + cursor = Math.max(cursor, Number(page.nextCursor || cursor)); + if (!page.hasMore) break; + } + if (!historical && state.snapshot) { + state.snapshot.cursor = Math.max(Number(state.snapshot.cursor || 0), cursor); + } + return { snapshotRequired: false, changed }; +} + +function applySnapshot(snapshot) { + if (!snapshot || typeof snapshot.streamId !== 'string') { + throw new Error('The host returned an invalid LoopX snapshot.'); + } + const previousStreamId = state.snapshot && state.snapshot.streamId; + state.snapshot = snapshot; + if (previousStreamId && previousStreamId !== snapshot.streamId) { + replaceStreamEvents(snapshot.streamId); + } + if (state.selectedTaskId && !taskForId(state.selectedTaskId)) { + state.selectedTaskId = null; + } + state.connected = true; + view.connectionLabel.textContent = text('connected'); + view.root.setAttribute('aria-busy', 'false'); + renderAll(); +} + +async function attachSnapshot(loadHistory = false) { + if (!app || !app.loopx) { + showBridgeUnavailable(); + return; + } + if (state.syncing) { + state.syncRequested = true; + return; + } + state.syncing = true; + setButtonBusy(view.syncButton, true); + try { + do { + state.syncRequested = false; + const knownStreamId = state.snapshot && state.snapshot.streamId; + const afterCursor = state.snapshot && state.snapshot.cursor; + view.connectionLabel.textContent = state.connected ? text('reconnecting') : text('connecting'); + const response = await app.loopx.attach({ + ...(knownStreamId ? { knownStreamId } : {}), + ...(Number.isSafeInteger(afterCursor) ? { afterCursor } : {}), + }); + applySnapshot(response && response.snapshot); + const snapshot = state.snapshot; + if (loadHistory && state.events.length === 0 && snapshot.cursor > 0) { + const replay = await replayEvents(snapshot.streamId, 0, true); + if (replay.changed) renderLogs(); + } + view.streamState.textContent = text('streamCurrent'); + } while (state.syncRequested); + } catch (error) { + state.connected = false; + view.connectionLabel.textContent = text('connectionFailed'); + view.streamState.textContent = text('connectionFailed'); + showNotice(errorMessage(error), 'error'); + } finally { + state.syncing = false; + setButtonBusy(view.syncButton, false); + } +} + +async function recoverEventGap(event) { + if (state.gapRecovery) return state.gapRecovery; + state.gapRecovery = (async () => { + try { + const snapshot = state.snapshot; + if (!snapshot || event.streamId !== snapshot.streamId) { + await attachSnapshot(false); + return; + } + const replay = await replayEvents(snapshot.streamId, snapshot.cursor, false); + if (replay.snapshotRequired) { + await attachSnapshot(false); + return; + } + if (event.cursor > state.snapshot.cursor) { + addEvent(event); + state.snapshot.cursor = event.cursor; + } + renderLogs(); + renderStreamStatus(); + } catch (error) { + showNotice(errorMessage(error), 'error'); + await attachSnapshot(false); + } finally { + state.gapRecovery = null; + } + })(); + return state.gapRecovery; +} + +function onLoopxEvent(payload) { + const event = payload && payload.event ? payload.event : payload; + if (!validEvent(event)) return; + if (!state.snapshot || event.streamId !== state.snapshot.streamId) { + void recoverEventGap(event); + return; + } + const cursor = Number(state.snapshot.cursor || 0); + if (event.cursor > cursor + 1) { + void recoverEventGap(event); + return; + } + const changed = addEvent(event); + state.snapshot.cursor = Math.max(cursor, event.cursor); + if (changed) { + renderLogs(); + renderStreamStatus(); + } + if (SNAPSHOT_EVENT_KINDS.has(event.kind)) { + state.syncRequested = true; + queueMicrotask(() => void attachSnapshot(false)); + } +} + +function showBridgeUnavailable() { + state.connected = false; + view.root.setAttribute('aria-busy', 'false'); + view.connectionLabel.textContent = text('connectionFailed'); + view.unsupportedReason.textContent = text('bridgeUnavailable'); + view.unsupportedBanner.hidden = false; + view.resolveButton.disabled = true; + view.retryEnvironment.disabled = true; +} + +function renderExecutionSupport() { + const snapshot = state.snapshot; + const supported = snapshotSupported(); + view.unsupportedBanner.hidden = !snapshot || supported; + if (snapshot && !supported) { + view.unsupportedReason.textContent = snapshot.unsupportedReason || text('unsupportedDefault'); + } + view.resolveButton.disabled = !supported; + view.retryEnvironment.disabled = !supported; +} + +function environmentFact(name, label, fact) { + const element = document.createElement('article'); + const status = fact && fact.status ? fact.status : 'unknown'; + element.className = 'environment-fact'; + element.dataset.status = status; + + const title = document.createElement('div'); + title.className = 'environment-fact__title'; + const strong = document.createElement('strong'); + strong.textContent = label; + const value = document.createElement('span'); + value.textContent = fact && fact.version ? fact.version : statusLabel(status); + title.append(strong, value); + element.append(title); + + const detail = document.createElement('p'); + detail.textContent = (fact && (fact.detail || fact.remediation)) || statusLabel(status); + detail.title = detail.textContent; + element.append(detail); + element.dataset.fact = name; + return element; +} + +function renderEnvironment() { + const environment = state.snapshot && state.snapshot.environment; + const status = environment && environment.status ? environment.status : 'unknown'; + view.environmentDot.dataset.status = status; + view.environmentStatus.textContent = statusLabel(status); + view.environmentChecked.textContent = environment && environment.checkedAt + ? text('updated', { duration: relativeLabel(environment.checkedAt) }) + : ''; + + const core = environment && environment.core ? environment.core : {}; + const optional = environment && environment.optional ? environment.optional : {}; + view.coreEnvironmentList.replaceChildren( + environmentFact('sidecar', text('sidecar'), core.sidecar), + environmentFact('gitWorktree', text('gitWorktree'), core.gitWorktree), + environmentFact('agentModel', text('agentModel'), core.agentModel), + ); + view.optionalEnvironmentList.replaceChildren( + environmentFact('pythonFallback', text('pythonFallback'), optional.pythonFallback), + environmentFact('openViking', text('openViking'), optional.openViking), + environmentFact('githubAuth', text('githubAuth'), optional.githubAuth), + ); +} + +function taskButton(task) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'task-item'; + if (task.taskId === state.selectedTaskId) button.classList.add('is-selected'); + button.dataset.taskId = task.taskId; + button.setAttribute('aria-pressed', String(task.taskId === state.selectedTaskId)); + + const icon = document.createElement('span'); + icon.className = 'task-item__icon'; + icon.setAttribute('aria-hidden', 'true'); + icon.textContent = task.identity && task.identity.item && task.identity.item.kind === 'pr' ? 'PR' : '#'; + + const main = document.createElement('span'); + main.className = 'task-item__main'; + const label = document.createElement('strong'); + label.textContent = itemLabel(task.identity && task.identity.item); + const meta = document.createElement('small'); + const activity = task.lastOutputAt || task.updatedAt; + meta.textContent = `${phaseLabel(task.phase)} · ${relativeLabel(activity)}`; + main.append(label, meta); + + const taskState = document.createElement('span'); + taskState.className = 'task-item__state status-dot'; + taskState.dataset.status = task.state || 'recovery_required'; + taskState.title = stateLabel(task.state); + button.append(icon, main, taskState); + button.addEventListener('click', () => selectTask(task.taskId)); + return button; +} + +function renderTasks() { + const tasks = state.snapshot && Array.isArray(state.snapshot.tasks) ? state.snapshot.tasks : []; + const sorted = [...tasks].sort((left, right) => Number(right.updatedAt || 0) - Number(left.updatedAt || 0)); + const fragment = document.createDocumentFragment(); + sorted.forEach((task) => fragment.append(taskButton(task))); + view.taskItems.replaceChildren(fragment); + view.taskCount.textContent = String(tasks.length); + view.taskEmpty.hidden = tasks.length !== 0; + view.allActivity.classList.toggle('is-selected', !state.selectedTaskId); + view.allActivity.setAttribute('aria-pressed', String(!state.selectedTaskId)); + view.allActivityMeta.textContent = text('eventCount', { value: state.events.length }); +} + +function latestGate(taskId) { + for (let index = state.events.length - 1; index >= 0; index -= 1) { + const event = state.events[index]; + if (event.taskId !== taskId || event.kind !== 'approval_required') continue; + const details = event.details || {}; + const gateId = details.gateId || details.gate_id || details.id; + if (gateId) return { event, gateId }; + } + return null; +} + +function makeActionButton(label, action, task, tone) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = tone === 'danger' ? 'danger-button' : 'text-button'; + button.textContent = label; + button.addEventListener('click', () => { + if (action === 'gate') openGateDialog(task); + else void performAction(action, task); + }); + return button; +} + +function renderTaskActions(task) { + view.taskActions.replaceChildren(); + if (!task || !snapshotSupported()) return; + const fragment = document.createDocumentFragment(); + if (['preparing', 'queued', 'running', 'retry_wait'].includes(task.state)) { + fragment.append(makeActionButton(text('pause'), 'pause', task)); + } + if (['stopped', 'recovery_required', 'failed'].includes(task.state)) { + fragment.append(makeActionButton(text('resume'), 'resume', task)); + } + if (task.state === 'waiting_for_user') { + fragment.append(makeActionButton(text('approvalGate'), 'gate', task)); + } + if (['stopped', 'completed', 'failed'].includes(task.state)) { + fragment.append(makeActionButton(text('archive'), 'archive', task)); + } + if (task.state === 'archived') { + fragment.append(makeActionButton(text('restore'), 'restore', task)); + } + view.taskActions.append(fragment); +} + +function renderLiveness() { + const task = selectedTask(); + view.livenessPanel.hidden = !task; + view.selectedState.hidden = !task; + if (!task) { + view.selectedSummary.textContent = text('allTaskEvents'); + view.logTitle.textContent = text('activity'); + return; + } + view.logTitle.textContent = itemLabel(task.identity && task.identity.item); + view.selectedSummary.textContent = text('selectedTaskEvents'); + view.selectedState.hidden = false; + view.selectedState.dataset.state = task.state; + view.selectedState.textContent = stateLabel(task.state); + view.livenessDot.dataset.status = task.state; + view.livenessPhase.textContent = phaseLabel(task.phase); + + const lastActivity = task.lastOutputAt || task.updatedAt; + const idleDuration = Date.now() - normalizeTimestamp(lastActivity); + if (task.state === 'running' && idleDuration > 120000) { + view.livenessSince.textContent = text('noRecentOutput', { duration: durationLabel(idleDuration) }); + view.livenessDot.dataset.status = 'degraded'; + } else if (task.lastOutputAt) { + view.livenessSince.textContent = text('lastOutput', { duration: relativeLabel(task.lastOutputAt) }); + } else { + view.livenessSince.textContent = text('updated', { duration: relativeLabel(task.updatedAt) }); + } + + view.livenessTool.hidden = !task.currentTool; + view.livenessTool.textContent = task.currentTool ? `${text('currentTool')}: ${task.currentTool}` : ''; + view.livenessTurn.hidden = !task.currentTurnId; + view.livenessTurn.textContent = task.currentTurnId ? `${text('turn')}: ${task.currentTurnId}` : ''; + const deadline = normalizeTimestamp(task.deadlineAt); + view.livenessDeadline.hidden = !deadline; + if (deadline) { + const delta = deadline - Date.now(); + view.livenessDeadline.textContent = delta >= 0 + ? text('deadlineRemaining', { duration: durationLabel(delta) }) + : text('deadlinePassed', { duration: durationLabel(-delta) }); + } + view.livenessError.hidden = !task.error; + view.livenessError.textContent = task.error || ''; + renderTaskActions(task); +} + +function eventMatches(event) { + if (state.selectedTaskId && event.taskId !== state.selectedTaskId) return false; + if (state.errorsOnly && event.level !== 'error') return false; + if ( + state.logMode === 'key' + && !event.important + && event.level !== 'warning' + && event.level !== 'error' + && !KEY_EVENT_KINDS.has(event.kind) + ) return false; + if (!state.query) return true; + const details = event.details ? Object.values(event.details).join(' ') : ''; + const haystack = [event.message, event.source, event.phase, event.kind, event.toolName, details] + .filter(Boolean) + .join(' ') + .toLowerCase(); + return haystack.includes(state.query); +} + +function eventRow(event) { + const row = document.createElement('li'); + row.className = 'log-row'; + row.dataset.level = event.level || 'info'; + row.dataset.cursor = String(event.cursor); + + const time = document.createElement('time'); + time.className = 'log-time'; + time.dateTime = new Date(normalizeTimestamp(event.occurredAt)).toISOString(); + time.textContent = clockLabel(event.occurredAt); + + const level = document.createElement('span'); + level.className = 'event-level'; + level.dataset.level = event.level || 'info'; + level.textContent = String(event.level || 'info').toUpperCase(); + + const source = document.createElement('span'); + source.className = 'log-source'; + source.textContent = event.source || 'controller'; + + const content = document.createElement('div'); + content.className = 'log-content'; + const message = document.createElement('div'); + message.className = 'log-message'; + message.textContent = event.message || event.kind || 'event'; + content.append(message); + + const metaValues = []; + if (!state.selectedTaskId && event.taskId) metaValues.push(event.taskId); + if (event.phase) metaValues.push(phaseLabel(event.phase)); + if (event.toolName) metaValues.push(`${text('currentTool')}: ${event.toolName}`); + if (event.deadlineAt) metaValues.push(`${text('deadline')}: ${clockLabel(event.deadlineAt)}`); + if (metaValues.length) { + const meta = document.createElement('div'); + meta.className = 'log-meta'; + metaValues.forEach((value) => { + const span = document.createElement('span'); + span.textContent = value; + meta.append(span); + }); + content.append(meta); + } + + const detailEntries = Object.entries(event.details || {}); + if (detailEntries.length) { + const details = document.createElement('details'); + details.className = 'log-details'; + const summary = document.createElement('summary'); + summary.textContent = text('details'); + const list = document.createElement('dl'); + detailEntries.forEach(([key, value]) => { + const term = document.createElement('dt'); + term.textContent = key; + const description = document.createElement('dd'); + description.textContent = String(value); + list.append(term, description); + }); + details.append(summary, list); + content.append(details); + } + + if (event.kind === 'approval_required' && event.taskId) { + const task = taskForId(event.taskId); + if (task) { + const actions = document.createElement('div'); + actions.className = 'task-actions log-meta'; + actions.append(makeActionButton(text('approvalGate'), 'gate', task)); + content.append(actions); + } + } + + row.append(time, level, source, content); + return row; +} + +function renderLogs() { + const wasFollowing = state.followLogs; + const matches = state.events.filter(eventMatches); + state.visibleEvents = matches; + const visible = matches.slice(-MAX_RENDERED_EVENTS); + const fragment = document.createDocumentFragment(); + visible.forEach((event) => fragment.append(eventRow(event))); + view.logList.replaceChildren(fragment); + view.logEmpty.hidden = visible.length !== 0; + view.visibleLogCount.textContent = `${text('visible')}: ${visible.length}/${matches.length}`; + if (wasFollowing) { + requestAnimationFrame(() => { + view.logScroll.scrollTop = view.logScroll.scrollHeight; + view.newEvents.hidden = true; + }); + } else if (visible.length) { + view.newEvents.hidden = false; + } +} + +function renderStreamStatus() { + const snapshot = state.snapshot; + view.streamState.textContent = state.connected ? text('streamCurrent') : text('notConnected'); + view.streamCursor.textContent = snapshot + ? `${text('cursor')}: ${snapshot.cursor}` + : `${text('cursor')}: --`; +} + +function renderAll() { + renderExecutionSupport(); + renderEnvironment(); + renderTasks(); + renderLiveness(); + renderLogs(); + renderStreamStatus(); +} + +function selectTask(taskId) { + state.selectedTaskId = taskId || null; + renderTasks(); + renderLiveness(); + renderLogs(); +} + +function factValue(value, fallback = '--') { + return value == null || value === '' ? fallback : String(value); +} + +function renderPreview(preview) { + state.preview = preview; + const repository = preview.repository || {}; + view.intakeDialogTitle.textContent = repositoryLabel(repository); + view.previewRepository.textContent = repositoryLabel(repository); + view.previewWorkspace.textContent = preview.workspace && preview.workspace.path + ? preview.workspace.path + : text(`workspace_${(preview.workspace && preview.workspace.disposition) || 'unavailable'}`); + view.previewModel.textContent = factValue(preview.model && preview.model.modelId, view.modelSelect.value); + view.previewImages.textContent = preview.model && preview.model.supportsImages + ? text('supported') + : text('unsupported'); + + const candidates = Array.isArray(preview.candidates) ? preview.candidates : []; + view.candidateCount.textContent = String(candidates.length); + const candidateFragment = document.createDocumentFragment(); + candidates.forEach((candidate) => { + const label = document.createElement('label'); + label.className = 'candidate-item'; + label.dataset.state = candidate.state || 'unknown'; + const input = document.createElement('input'); + input.type = 'checkbox'; + input.name = 'candidate'; + input.value = itemKey(candidate.key); + const resolved = candidate.state === 'closed' || candidate.state === 'merged'; + input.disabled = resolved; + input.checked = !resolved && candidate.defaultSelected === true; + input.addEventListener('change', updateCreateButton); + + const copy = document.createElement('span'); + copy.className = 'candidate-copy'; + const title = document.createElement('strong'); + title.textContent = candidate.title || itemLabel(candidate.key); + const meta = document.createElement('small'); + meta.textContent = candidate.fromRepository + ? `${itemLabel(candidate.key)} · ${text('fromRepository')}` + : itemLabel(candidate.key); + copy.append(title, meta); + + const itemState = document.createElement('span'); + itemState.className = 'candidate-state'; + itemState.textContent = resolved ? text('resolvedItem') : text('openItem'); + label.append(input, copy, itemState); + candidateFragment.append(label); + }); + view.candidateList.replaceChildren(candidateFragment); + + const scopes = Array.isArray(preview.permissionScopes) ? preview.permissionScopes : []; + const permissionFragment = document.createDocumentFragment(); + scopes.forEach((scope) => { + const highRisk = HIGH_RISK_SCOPES.has(scope); + const label = document.createElement('label'); + label.className = 'permission-item'; + label.dataset.risk = highRisk ? 'high' : 'standard'; + const input = document.createElement('input'); + input.type = 'checkbox'; + input.name = 'permission'; + input.value = scope; + input.checked = !highRisk; + input.addEventListener('change', updateCreateButton); + const copy = document.createElement('span'); + copy.className = 'permission-copy'; + const title = document.createElement('strong'); + title.textContent = scopeLabel(scope); + const detail = document.createElement('small'); + detail.textContent = highRisk ? text('scopeHighRisk') : text('scopeStandard'); + copy.append(title, detail); + const risk = document.createElement('span'); + risk.className = 'candidate-state'; + risk.textContent = highRisk ? '!' : ''; + label.append(input, copy, risk); + permissionFragment.append(label); + }); + view.permissionList.replaceChildren(permissionFragment); + + const warnings = []; + if (preview.truncated) warnings.push(text('truncatedCandidates')); + if ( + candidates.some((candidate) => candidate.hasImages) + && preview.model + && !preview.model.supportsImages + ) warnings.push(text('imageWarning')); + if (preview.model && preview.model.available === false) warnings.push(text('modelUnavailable')); + if (preview.workspace && preview.workspace.disposition === 'unavailable') { + warnings.push(text('workspaceUnavailable')); + } + view.intakeWarning.hidden = warnings.length === 0; + view.intakeWarning.textContent = warnings.join(' '); + updateCreateButton(); +} + +function selectedPreviewItems() { + if (!state.preview) return []; + const selectedKeys = new Set( + [...view.candidateList.querySelectorAll('input[name="candidate"]:checked')] + .map((input) => input.value), + ); + return (state.preview.candidates || []) + .filter((candidate) => selectedKeys.has(itemKey(candidate.key))) + .map((candidate) => candidate.key); +} + +function selectedPermissionScopes() { + return [...view.permissionList.querySelectorAll('input[name="permission"]:checked')] + .map((input) => input.value); +} + +function updateCreateButton() { + const itemCount = selectedPreviewItems().length; + const previewReady = Boolean(state.preview) + && (!state.preview.model || state.preview.model.available !== false) + && (!state.preview.workspace || state.preview.workspace.disposition !== 'unavailable'); + view.createButton.disabled = itemCount === 0 || !previewReady; + view.createButton.textContent = itemCount > 1 + ? `${text('createTasks')} (${itemCount})` + : text('createTasks'); +} + +async function loadModelCatalog() { + const select = view.modelSelect; + if (!select || select.tagName !== 'SELECT') return; + if (!app || !app.loopx || typeof app.loopx.listModels !== 'function') return; + try { + const models = await app.loopx.listModels(); + const current = select.value || 'auto'; + select.replaceChildren(); + const auto = document.createElement('option'); + auto.value = 'auto'; + auto.textContent = text('modelAuto'); + auto.selected = current === 'auto'; + select.appendChild(auto); + for (const model of Array.isArray(models) ? models : []) { + if (!model || !model.id) continue; + const option = document.createElement('option'); + option.value = model.id; + const tag = model.isDefault === true ? ` · ${text('modelPrimaryTag')}` : ''; + option.textContent = `${model.name || model.modelName || model.id}${tag}`; + option.selected = current === model.id; + select.appendChild(option); + } + } catch (error) { + // The seeded "auto" option remains a safe fallback when the catalog is unavailable. + } +} + +async function resolveIntake() { + const input = view.intakeInput.value.trim(); + if (!input) { + view.intakeInput.focus(); + return; + } + if (!snapshotSupported()) { + showNotice(text('intakeUnavailable'), 'error'); + return; + } + setButtonBusy(view.resolveButton, true); + showNotice(text('resolving')); + try { + const response = await app.loopx.resolveIntake({ + input, + modelId: view.modelSelect.value, + }); + if (!response || !response.preview) throw new Error(text('previewExpired')); + renderPreview(response.preview); + showNotice(''); + view.intakeDialog.showModal(); + } catch (error) { + showNotice(errorMessage(error), 'error'); + } finally { + setButtonBusy(view.resolveButton, false); + } +} + +function outcomeMessage(outcome) { + if (outcome && outcome.message) return outcome.message; + const kind = outcome && outcome.kind; + if (kind === 'created') return text('taskCreated'); + if (kind === 'opened_existing') return text('openedExisting'); + if (kind === 'closed_noop') return text('closedNoop'); + if (kind === 'needs_live_verification') return text('liveVerification'); + if (kind === 'retry_confirmation_required') return text('retryRequired'); + return kind || text('taskCreated'); +} + +async function createTasks(retryTerminal) { + if (!state.preview) { + showNotice(text('previewExpired'), 'error'); + return; + } + const selectedItems = selectedPreviewItems(); + if (!selectedItems.length) { + view.intakeWarning.hidden = false; + view.intakeWarning.textContent = text('selectAtLeastOne'); + return; + } + const grantedScopes = selectedPermissionScopes(); + if (!grantedScopes.length) { + view.intakeWarning.hidden = false; + view.intakeWarning.textContent = text('selectPermissions'); + return; + } + const createRequest = { + clientRequestId: requestId(), + previewFingerprint: state.preview.fingerprint, + selectedItems, + modelId: state.preview.model && state.preview.model.modelId + ? state.preview.model.modelId + : view.modelSelect.value, + grantedScopes, + retryTerminal: Boolean(retryTerminal), + }; + state.pendingCreate = createRequest; + view.createButton.disabled = true; + view.retryConfirm.disabled = true; + try { + const response = await app.loopx.createTask(createRequest); + const outcomes = response && Array.isArray(response.outcomes) ? response.outcomes : []; + const retryOutcomes = outcomes.filter((outcome) => outcome.kind === 'retry_confirmation_required'); + if (!retryTerminal && retryOutcomes.length) { + state.pendingRetry = { + preview: state.preview, + itemKeys: new Set(retryOutcomes.map((outcome) => itemKey(outcome.item))), + scopes: grantedScopes, + }; + view.retryMessage.textContent = retryOutcomes.map(outcomeMessage).join(' '); + view.intakeDialog.close(); + view.retryDialog.showModal(); + return; + } + const messages = outcomes.map(outcomeMessage); + const hasError = outcomes.some((outcome) => outcome.kind === 'needs_live_verification'); + showNotice(messages.join(' '), hasError ? 'error' : 'success'); + view.intakeDialog.close(); + view.retryDialog.close(); + state.preview = null; + state.pendingRetry = null; + await attachSnapshot(false); + const openedTask = outcomes.find((outcome) => outcome.taskId); + if (openedTask && openedTask.taskId) selectTask(openedTask.taskId); + } catch (error) { + showNotice(errorMessage(error), 'error'); + } finally { + state.pendingCreate = null; + view.retryConfirm.disabled = false; + updateCreateButton(); + } +} + +async function confirmRetry() { + const pending = state.pendingRetry; + if (!pending || !state.preview) { + view.retryDialog.close(); + showNotice(text('previewExpired'), 'error'); + return; + } + view.candidateList.querySelectorAll('input[name="candidate"]').forEach((input) => { + input.checked = pending.itemKeys.has(input.value); + }); + await createTasks(true); +} + +async function performAction(action, task, extra = {}) { + if (!snapshotSupported()) { + showNotice(text('intakeUnavailable'), 'error'); + return false; + } + const expectedRevision = task + ? Number(task.revision || 0) + : Number((state.snapshot && state.snapshot.revision) || 0); + const request = { + action, + clientRequestId: requestId(), + expectedRevision, + ...(task ? { taskId: task.taskId } : {}), + ...(extra.gateId ? { gateId: extra.gateId } : {}), + ...(extra.note ? { note: extra.note } : {}), + }; + try { + const response = await app.loopx.action(request); + if (response && response.task && state.snapshot) { + const index = state.snapshot.tasks.findIndex((item) => item.taskId === response.task.taskId); + if (index >= 0) state.snapshot.tasks.splice(index, 1, response.task); + else state.snapshot.tasks.push(response.task); + state.snapshot.revision = Math.max(state.snapshot.revision, response.currentRevision || 0); + } + const status = response && response.status; + if (status === 'revision_conflict') { + showNotice(response.message || text('revisionConflict'), 'error'); + await attachSnapshot(false); + return false; + } else if (status === 'rejected') { + showNotice(response.message || text('actionRejected'), 'error'); + return false; + } else if (status === 'duplicate') { + showNotice(response.message || text('actionDuplicate')); + } else { + showNotice(response && response.message ? response.message : text('actionApplied'), 'success'); + await attachSnapshot(false); + } + return true; + } catch (error) { + showNotice(errorMessage(error), 'error'); + await attachSnapshot(false); + return false; + } +} + +function openGateDialog(task) { + const gate = latestGate(task.taskId); + if (!gate) { + showNotice(text('noGate'), 'error'); + return; + } + state.gate = { task, gateId: gate.gateId }; + view.gateTitle.textContent = itemLabel(task.identity && task.identity.item); + view.gateMessage.textContent = gate.event.message || text('approvalNeeded'); + view.gateNote.value = ''; + view.gateDialog.showModal(); + view.gateNote.focus(); +} + +async function answerGate(action) { + if (!state.gate) return; + const gate = state.gate; + const note = view.gateNote.value.trim(); + view.gateApprove.disabled = true; + view.gateReject.disabled = true; + try { + const applied = await performAction(action, gate.task, { gateId: gate.gateId, note }); + if (applied) { + state.gate = null; + view.gateDialog.close(); + } + } finally { + view.gateApprove.disabled = false; + view.gateReject.disabled = false; + } +} + +function filteredExportEvents() { + return state.visibleEvents.map((event) => ({ + streamId: event.streamId, + cursor: event.cursor, + taskId: event.taskId || null, + kind: event.kind, + level: event.level, + source: event.source, + phase: event.phase || null, + message: event.message, + important: Boolean(event.important), + toolName: event.toolName || null, + deadlineAt: event.deadlineAt || null, + details: event.details || {}, + occurredAt: event.occurredAt, + })); +} + +function exportLogs() { + const payload = { + schemaVersion: state.snapshot && state.snapshot.schemaVersion, + streamId: state.snapshot && state.snapshot.streamId, + exportedAt: Date.now(), + selectedTaskId: state.selectedTaskId, + mode: state.logMode, + query: state.query, + errorsOnly: state.errorsOnly, + events: filteredExportEvents(), + }; + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `loopx-events-${Date.now()}.json`; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + showNotice(text('logsExported'), 'success'); +} + +function setLogMode(mode) { + state.logMode = mode; + const keyMode = mode === 'key'; + view.modeKey.classList.toggle('is-active', keyMode); + view.modeKey.setAttribute('aria-checked', String(keyMode)); + view.modeFull.classList.toggle('is-active', !keyMode); + view.modeFull.setAttribute('aria-checked', String(!keyMode)); + renderLogs(); +} + +function bindEvents() { + view.intakeForm.addEventListener('submit', (event) => { + event.preventDefault(); + void resolveIntake(); + }); + view.syncButton.addEventListener('click', () => void attachSnapshot(false)); + view.retryEnvironment.addEventListener('click', async () => { + await performAction('retry_environment', null); + }); + view.collapseTasks.addEventListener('click', () => { + state.railCollapsed = !state.railCollapsed; + view.taskRail.classList.toggle('is-collapsed', state.railCollapsed); + view.taskRail.parentElement.classList.toggle('tasks-collapsed', state.railCollapsed); + view.collapseTasks.setAttribute('aria-expanded', String(!state.railCollapsed)); + view.collapseTasks.setAttribute( + 'title', + state.railCollapsed ? text('expandTasks') : text('collapseTasks'), + ); + }); + view.allActivity.addEventListener('click', () => selectTask(null)); + view.modeKey.addEventListener('click', () => setLogMode('key')); + view.modeFull.addEventListener('click', () => setLogMode('full')); + view.logSearch.addEventListener('input', () => { + state.query = view.logSearch.value.trim().toLowerCase(); + renderLogs(); + }); + view.errorsOnly.addEventListener('change', () => { + state.errorsOnly = view.errorsOnly.checked; + renderLogs(); + }); + view.exportLogs.addEventListener('click', exportLogs); + view.logScroll.addEventListener('scroll', () => { + const remaining = view.logScroll.scrollHeight - view.logScroll.scrollTop - view.logScroll.clientHeight; + state.followLogs = remaining < 40; + if (state.followLogs) view.newEvents.hidden = true; + }, { passive: true }); + view.newEvents.addEventListener('click', () => { + state.followLogs = true; + renderLogs(); + }); + document.querySelectorAll('.dialog-close').forEach((button) => { + button.addEventListener('click', () => button.closest('dialog').close()); + }); + view.intakeConfirmForm.addEventListener('submit', (event) => { + event.preventDefault(); + void createTasks(false); + }); + view.retryCancel.addEventListener('click', () => { + state.pendingRetry = null; + view.retryDialog.close(); + }); + view.retryConfirm.addEventListener('click', (event) => { + event.preventDefault(); + void confirmRetry(); + }); + view.gateCancel.addEventListener('click', () => { + state.gate = null; + view.gateDialog.close(); + }); + view.gateForm.addEventListener('submit', (event) => { + event.preventDefault(); + void answerGate('approve'); + }); + view.gateReject.addEventListener('click', () => void answerGate('reject')); +} + +function updateLivenessClock() { + renderLiveness(); + renderTasks(); +} + +async function start() { + bindEvents(); + applyLocale(); + void loadModelCatalog(); + if (!app || !app.loopx) { + showBridgeUnavailable(); + return; + } + app.loopx.onEvent(onLoopxEvent); + if (typeof app.onLocaleChange === 'function') app.onLocaleChange(applyLocale); + if (typeof app.onActivate === 'function') app.onActivate(() => void attachSnapshot(false)); + window.addEventListener('beforeunload', () => { + if (app.loopx && typeof app.loopx.offEvent === 'function') { + app.loopx.offEvent(onLoopxEvent); + } + }); + window.setInterval(updateLivenessClock, 15000); + await attachSnapshot(true); +} + +void start(); diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/worker.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/worker.js new file mode 100644 index 0000000000..497888e912 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/worker.js @@ -0,0 +1,3 @@ +// Compatibility-only worker entry. LoopX execution is intentionally owned by +// the persistent BitFun host controller; this MiniApp worker is intentionally empty. +module.exports = {}; diff --git a/src/crates/contracts/product-domains/src/miniapp/loopx/mod.rs b/src/crates/contracts/product-domains/src/miniapp/loopx/mod.rs new file mode 100644 index 0000000000..786cc59476 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/loopx/mod.rs @@ -0,0 +1,9 @@ +//! LoopX MiniApp contracts, typed service port, and pure lifecycle policy. + +pub mod policy; +pub mod ports; +pub mod types; + +pub use policy::*; +pub use ports::*; +pub use types::*; diff --git a/src/crates/contracts/product-domains/src/miniapp/loopx/policy.rs b/src/crates/contracts/product-domains/src/miniapp/loopx/policy.rs new file mode 100644 index 0000000000..01fa448dc7 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/loopx/policy.rs @@ -0,0 +1,472 @@ +//! Pure parsing and lifecycle decisions for LoopX tasks. + +use super::types::{ + LoopxActionStatus, LoopxCoreEnvironmentFacts, LoopxEnvironmentFactStatus, + LoopxEnvironmentStatus, LoopxEventsPageStatus, LoopxExistingTask, LoopxIntakeCandidate, + LoopxIntakeTarget, LoopxIssueKey, LoopxItemKind, LoopxOptionalEnvironmentFacts, + LoopxPermissionScope, LoopxRemoteItemState, LoopxRepositoryKey, LoopxTaskState, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxIntakeParseErrorKind { + Empty, + UnsupportedHost, + InvalidRepository, + UnsupportedPath, + InvalidItemNumber, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LoopxIntakeParseError { + pub kind: LoopxIntakeParseErrorKind, + pub message: String, +} + +impl LoopxIntakeParseError { + fn new(kind: LoopxIntakeParseErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } +} + +impl std::fmt::Display for LoopxIntakeParseError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for LoopxIntakeParseError {} + +fn valid_owner(value: &str) -> bool { + !value.is_empty() + && value.len() <= 39 + && !value.starts_with('-') + && !value.ends_with('-') + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') +} + +fn valid_repository(value: &str) -> bool { + !value.is_empty() + && value != "." + && value != ".." + && value.len() <= 100 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) +} + +fn split_host_and_path(input: &str) -> Result<(String, String), LoopxIntakeParseError> { + let without_suffix = input + .split(['?', '#']) + .next() + .unwrap_or_default() + .trim() + .trim_end_matches('/'); + + if let Some(rest) = without_suffix.strip_prefix("git@") { + let Some((host, path)) = rest.split_once(':') else { + return Err(LoopxIntakeParseError::new( + LoopxIntakeParseErrorKind::InvalidRepository, + "Invalid GitHub SSH repository URL", + )); + }; + return Ok((host.to_ascii_lowercase(), path.to_string())); + } + + let schemeless = without_suffix + .strip_prefix("https://") + .or_else(|| without_suffix.strip_prefix("http://")) + .unwrap_or(without_suffix); + + if let Some((host, path)) = schemeless.split_once('/') { + if host.eq_ignore_ascii_case("github.com") || host.eq_ignore_ascii_case("www.github.com") { + return Ok(("github.com".to_string(), path.to_string())); + } + if host.contains('.') || input.contains("://") { + return Err(LoopxIntakeParseError::new( + LoopxIntakeParseErrorKind::UnsupportedHost, + "Only github.com repositories are supported", + )); + } + } + + if !input.contains("://") { + return Ok(("github.com".to_string(), schemeless.to_string())); + } + + Err(LoopxIntakeParseError::new( + LoopxIntakeParseErrorKind::UnsupportedHost, + "Only github.com repositories are supported", + )) +} + +/// Parse one GitHub repository, issues-list, issue, or pull-request target. +pub fn parse_loopx_intake(input: &str) -> Result { + let input = input.trim(); + if input.is_empty() { + return Err(LoopxIntakeParseError::new( + LoopxIntakeParseErrorKind::Empty, + "A GitHub repository, issue, or pull-request URL is required", + )); + } + if input.chars().any(char::is_whitespace) { + return Err(LoopxIntakeParseError::new( + LoopxIntakeParseErrorKind::UnsupportedPath, + "LoopX intake accepts one GitHub target at a time", + )); + } + + let (host, path) = split_host_and_path(input)?; + if host != "github.com" { + return Err(LoopxIntakeParseError::new( + LoopxIntakeParseErrorKind::UnsupportedHost, + "Only github.com repositories are supported", + )); + } + + let segments = path + .split('/') + .filter(|segment| !segment.is_empty()) + .collect::>(); + if segments.len() < 2 { + return Err(LoopxIntakeParseError::new( + LoopxIntakeParseErrorKind::InvalidRepository, + "GitHub target must include an owner and repository", + )); + } + + let owner = segments[0].to_ascii_lowercase(); + let repository = segments[1] + .strip_suffix(".git") + .unwrap_or(segments[1]) + .to_ascii_lowercase(); + if !valid_owner(&owner) || !valid_repository(&repository) { + return Err(LoopxIntakeParseError::new( + LoopxIntakeParseErrorKind::InvalidRepository, + "Invalid GitHub owner or repository name", + )); + } + let repository_key = LoopxRepositoryKey { + host, + owner, + repository, + }; + + match segments.as_slice() { + [_, _] | [_, _, "issues"] | [_, _, "pulls"] => Ok(LoopxIntakeTarget::Repository { + repository: repository_key, + }), + [_, _, collection @ ("issues" | "pull"), number] => { + let number = number.parse::().map_err(|_| { + LoopxIntakeParseError::new( + LoopxIntakeParseErrorKind::InvalidItemNumber, + "GitHub issue or pull-request number is invalid", + ) + })?; + if number == 0 { + return Err(LoopxIntakeParseError::new( + LoopxIntakeParseErrorKind::InvalidItemNumber, + "GitHub issue or pull-request number must be positive", + )); + } + let kind = if *collection == "issues" { + LoopxItemKind::Issue + } else { + LoopxItemKind::PullRequest + }; + Ok(LoopxIntakeTarget::Item { + item: LoopxIssueKey { + repository: repository_key, + kind, + number, + }, + }) + } + _ => Err(LoopxIntakeParseError::new( + LoopxIntakeParseErrorKind::UnsupportedPath, + "Paste a repository, issues-list, issue, or pull-request URL", + )), + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "decision", rename_all = "snake_case")] +pub enum LoopxTransitionDecision { + NoChange, + Allowed { next: LoopxTaskState }, + Rejected, +} + +pub fn decide_task_transition( + current: LoopxTaskState, + next: LoopxTaskState, +) -> LoopxTransitionDecision { + if current == next { + return LoopxTransitionDecision::NoChange; + } + + let allowed = match current { + LoopxTaskState::Preparing => matches!( + next, + LoopxTaskState::Queued + | LoopxTaskState::Cancelling + | LoopxTaskState::RecoveryRequired + | LoopxTaskState::Failed + ), + LoopxTaskState::Queued => matches!( + next, + LoopxTaskState::Running + | LoopxTaskState::Cancelling + | LoopxTaskState::RecoveryRequired + | LoopxTaskState::Failed + ), + LoopxTaskState::Running => matches!( + next, + LoopxTaskState::Queued + | LoopxTaskState::WaitingForUser + | LoopxTaskState::RetryWait + | LoopxTaskState::Cancelling + | LoopxTaskState::RecoveryRequired + | LoopxTaskState::Completed + | LoopxTaskState::Failed + ), + LoopxTaskState::WaitingForUser => matches!( + next, + LoopxTaskState::Queued + | LoopxTaskState::Cancelling + | LoopxTaskState::RecoveryRequired + | LoopxTaskState::Failed + ), + LoopxTaskState::RetryWait => matches!( + next, + LoopxTaskState::Queued + | LoopxTaskState::Cancelling + | LoopxTaskState::RecoveryRequired + | LoopxTaskState::Failed + ), + LoopxTaskState::Cancelling => matches!( + next, + LoopxTaskState::Stopped | LoopxTaskState::RecoveryRequired | LoopxTaskState::Failed + ), + LoopxTaskState::Stopped | LoopxTaskState::Failed => { + matches!(next, LoopxTaskState::Queued | LoopxTaskState::Archived) + } + LoopxTaskState::RecoveryRequired => matches!( + next, + LoopxTaskState::Queued | LoopxTaskState::Stopped | LoopxTaskState::Failed + ), + LoopxTaskState::Completed => matches!(next, LoopxTaskState::Archived), + LoopxTaskState::Archived => matches!(next, LoopxTaskState::RecoveryRequired), + }; + + if allowed { + LoopxTransitionDecision::Allowed { next } + } else { + LoopxTransitionDecision::Rejected + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "decision", rename_all = "snake_case")] +pub enum LoopxRestartDecision { + Preserve { state: LoopxTaskState }, + RequireRecovery, +} + +pub fn decide_task_restart(state: LoopxTaskState) -> LoopxRestartDecision { + if state.was_executing_at_shutdown() { + LoopxRestartDecision::RequireRecovery + } else { + LoopxRestartDecision::Preserve { state } + } +} + +pub fn task_state_after_restart(state: LoopxTaskState) -> LoopxTaskState { + match decide_task_restart(state) { + LoopxRestartDecision::RequireRecovery => LoopxTaskState::RecoveryRequired, + LoopxRestartDecision::Preserve { state } => state, + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "decision", rename_all = "snake_case")] +pub enum LoopxDedupDecision { + CreateAttempt { + attempt: u32, + }, + OpenExisting { + task_id: String, + }, + RequireExplicitRetry { + previous_task_id: String, + next_attempt: u32, + }, + ClosedNoop, + NeedsLiveVerification, +} + +pub fn decide_task_dedup( + key: &LoopxIssueKey, + remote_state: LoopxRemoteItemState, + existing: &[LoopxExistingTask], + retry_terminal: bool, +) -> LoopxDedupDecision { + let mut matching = existing + .iter() + .filter(|task| &task.identity.item == key) + .collect::>(); + matching.sort_by_key(|task| task.identity.attempt); + + if let Some(active) = matching.iter().rev().find(|task| !task.state.is_terminal()) { + return LoopxDedupDecision::OpenExisting { + task_id: active.task_id.clone(), + }; + } + + if remote_state.is_resolved() { + return LoopxDedupDecision::ClosedNoop; + } + if remote_state == LoopxRemoteItemState::Unknown { + return LoopxDedupDecision::NeedsLiveVerification; + } + + let Some(previous) = matching.last() else { + return LoopxDedupDecision::CreateAttempt { attempt: 1 }; + }; + let next_attempt = previous.identity.attempt.saturating_add(1).max(1); + if retry_terminal { + LoopxDedupDecision::CreateAttempt { + attempt: next_attempt, + } + } else { + LoopxDedupDecision::RequireExplicitRetry { + previous_task_id: previous.task_id.clone(), + next_attempt, + } + } +} + +pub fn intake_scope_is_pregrantable(scope: LoopxPermissionScope) -> bool { + matches!( + scope, + LoopxPermissionScope::WorkspaceRead + | LoopxPermissionScope::WorkspaceWrite + | LoopxPermissionScope::GitLocal + | LoopxPermissionScope::GithubRead + | LoopxPermissionScope::AgentExecution + ) +} + +pub fn derive_environment_status( + core: &LoopxCoreEnvironmentFacts, + optional: &LoopxOptionalEnvironmentFacts, +) -> LoopxEnvironmentStatus { + let core_statuses = [ + core.sidecar.status, + core.git_worktree.status, + core.agent_model.status, + ]; + if core_statuses.contains(&LoopxEnvironmentFactStatus::Checking) { + return LoopxEnvironmentStatus::Checking; + } + if core_statuses.contains(&LoopxEnvironmentFactStatus::Unavailable) { + return LoopxEnvironmentStatus::Blocked; + } + if core_statuses.contains(&LoopxEnvironmentFactStatus::Unknown) { + return LoopxEnvironmentStatus::Unknown; + } + if core_statuses.contains(&LoopxEnvironmentFactStatus::Degraded) { + return LoopxEnvironmentStatus::Degraded; + } + + let optional_statuses = [ + optional.python_fallback.status, + optional.open_viking.status, + optional.github_auth.status, + ]; + if optional_statuses.iter().any(|status| { + matches!( + status, + LoopxEnvironmentFactStatus::Degraded | LoopxEnvironmentFactStatus::Unavailable + ) + }) { + LoopxEnvironmentStatus::Degraded + } else { + LoopxEnvironmentStatus::Ready + } +} + +pub fn decide_action_status( + client_request_already_applied: bool, + expected_revision: u64, + current_revision: u64, +) -> LoopxActionStatus { + if client_request_already_applied { + LoopxActionStatus::Duplicate + } else if expected_revision != current_revision { + LoopxActionStatus::RevisionConflict + } else { + LoopxActionStatus::Applied + } +} + +pub fn decide_events_page_status( + current_stream_id: &str, + requested_stream_id: &str, + after_cursor: u64, + oldest_retained_cursor: Option, + latest_cursor: u64, +) -> LoopxEventsPageStatus { + if current_stream_id != requested_stream_id || after_cursor > latest_cursor { + return LoopxEventsPageStatus::SnapshotRequired; + } + if let Some(oldest) = oldest_retained_cursor { + if after_cursor.saturating_add(1) < oldest { + return LoopxEventsPageStatus::SnapshotRequired; + } + } + LoopxEventsPageStatus::Current +} + +pub fn build_intake_fingerprint( + target: &LoopxIntakeTarget, + candidates: &[LoopxIntakeCandidate], + workspace_path: Option<&str>, + model_id: &str, + permission_scopes: &[LoopxPermissionScope], +) -> String { + let mut item_facts = candidates + .iter() + .map(|candidate| { + format!( + "{}:{:?}:{}:{}", + candidate.key.canonical_id(), + candidate.state, + candidate.has_images, + candidate.from_repository + ) + }) + .collect::>(); + item_facts.sort(); + let mut scopes = permission_scopes.to_vec(); + scopes.sort(); + scopes.dedup(); + let target_id = match target { + LoopxIntakeTarget::Repository { repository } => repository.canonical_id(), + LoopxIntakeTarget::Item { item } => item.canonical_id(), + }; + let payload = format!( + "target={target_id}\nitems={}\nworkspace={}\nmodel={model_id}\nscopes={scopes:?}", + item_facts.join("|"), + workspace_path.unwrap_or_default(), + ); + format!("sha256:{}", hex::encode(Sha256::digest(payload.as_bytes()))) +} diff --git a/src/crates/contracts/product-domains/src/miniapp/loopx/ports.rs b/src/crates/contracts/product-domains/src/miniapp/loopx/ports.rs new file mode 100644 index 0000000000..8ed5c2a7eb --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/loopx/ports.rs @@ -0,0 +1,674 @@ +//! Narrow service boundary for the pinned LoopX CLI adapter. + +use super::types::{ + LoopxIntakeCandidate, LoopxIntakeTarget, LoopxIssueKey, LoopxPermissionScope, + LoopxRepositoryKey, +}; +use serde::{Deserialize, Serialize}; +use std::future::Future; +use std::pin::Pin; + +pub type LoopxCliFuture<'a, T> = Pin> + Send + 'a>>; +pub type LoopxCliResult = Result; + +fn default_loopx_version() -> String { + super::types::LOOPX_PINNED_VERSION.to_string() +} + +fn default_cli_schema_version() -> u32 { + super::types::LOOPX_CLI_SCHEMA_VERSION +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxCliErrorKind { + #[default] + Backend, + InvalidInput, + NotFound, + VersionMismatch, + SchemaMismatch, + Conflict, + Cancelled, + Timeout, + Process, + Io, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliError { + pub kind: LoopxCliErrorKind, + pub message: String, + pub operation_id: Option, + pub retryable: bool, +} + +impl LoopxCliError { + pub fn new(kind: LoopxCliErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + operation_id: None, + retryable: false, + } + } + + pub fn for_operation(mut self, operation_id: impl Into) -> Self { + self.operation_id = Some(operation_id.into()); + self + } + + pub fn retryable(mut self, retryable: bool) -> Self { + self.retryable = retryable; + self + } +} + +impl std::fmt::Display for LoopxCliError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{:?}: {}", self.kind, self.message) + } +} + +impl std::error::Error for LoopxCliError {} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliCallContext { + pub operation_id: String, + pub deadline_at: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliGoalContext { + #[serde(flatten)] + pub call: LoopxCliCallContext, + pub task_id: String, + pub generation: u64, + pub worktree_path: String, + pub registry_path: String, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxCliProgressStage { + #[default] + StartingSidecar, + Handshake, + ResolvingIntake, + PlanningItem, + CreatingGoal, + InspectingGoal, + BuildingTurn, + AnsweringGate, + SettlingTurn, + Cancelling, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliProgress { + pub operation_id: String, + pub task_id: Option, + pub stage: LoopxCliProgressStage, + pub message: String, + pub occurred_at: i64, +} + +/// Synchronous projection hook; the controller persists and broadcasts events. +pub trait LoopxCliProgressSink: Send + Sync { + fn report(&self, progress: LoopxCliProgress); +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxCliSource { + #[default] + Unknown, + Bundled, + System, + PythonFallback, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliExecutableIdentity { + pub source: LoopxCliSource, + /// Adapter-owned executable identifier; never an argv fragment. + pub identity: String, + pub path: Option, + pub sha256: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliHandshakeRequest { + #[serde(flatten)] + pub call: LoopxCliCallContext, + #[serde(default = "default_loopx_version")] + pub required_loopx_version: String, + #[serde(default = "default_cli_schema_version")] + pub required_schema_version: u32, +} + +impl Default for LoopxCliHandshakeRequest { + fn default() -> Self { + Self { + call: LoopxCliCallContext::default(), + required_loopx_version: default_loopx_version(), + required_schema_version: default_cli_schema_version(), + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliManifest { + pub adapter_version: String, + pub loopx_version: String, + pub schema_version: u32, + pub executable: LoopxCliExecutableIdentity, + pub capabilities: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliTodoPlan { + pub role: String, + pub task_class: String, + pub action_kind: Option, + pub text: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliResolveIntakeRequest { + #[serde(flatten)] + pub call: LoopxCliCallContext, + pub input: String, + pub target: LoopxIntakeTarget, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliResolveIntakeResult { + pub target: LoopxIntakeTarget, + pub repository: LoopxRepositoryKey, + pub candidates: Vec, + pub truncated: bool, + pub resolved_at: i64, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxGithubAuthProbeRequest { + #[serde(flatten)] + pub call: LoopxCliCallContext, +} + +/// Result of the pre-flight GitHub access probe. The host surfaces this as the +/// `github_auth` environment fact so an auth/rate-limit failure is visible +/// before the user submits an intake, instead of surfacing as a 403 later. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxGithubAuthProbe { + /// Whether an authenticated GitHub identity is available. + pub authenticated: bool, + /// Remaining core API rate limit, when the endpoint reports one. + pub rate_limit_remaining: Option, + pub detail: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliPlanItemRequest { + #[serde(flatten)] + pub context: LoopxCliGoalContext, + pub item: LoopxIssueKey, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliIntakePlan { + pub item: LoopxIssueKey, + pub objective: String, + pub todos: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliCreateGoalRequest { + #[serde(flatten)] + pub context: LoopxCliGoalContext, + pub goal_id: String, + pub agent_id: String, + pub intake: LoopxCliIntakePlan, + pub granted_scopes: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliCreateGoalResult { + pub goal_id: String, + pub created: bool, + pub durable_revision: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliInspectGoalRequest { + #[serde(flatten)] + pub context: LoopxCliGoalContext, + pub goal_id: String, + pub agent_id: String, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxCliGoalState { + #[default] + Unknown, + Active, + WaitingForUser, + Completed, + Failed, + Archived, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxCliRunDecision { + #[default] + Wait, + RunNow, + WaitingForUser, + Complete, + Failed, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliGoalSnapshot { + pub goal_id: String, + pub state: LoopxCliGoalState, + pub durable_revision: String, + pub run_decision: LoopxCliRunDecision, + pub scheduler_hint_ms: Option, + pub open_todo_count: u32, + pub waiting_user_todo_count: u32, + pub last_turn_id: Option, + pub settlement_receipt_ids: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliBuildTurnRequest { + #[serde(flatten)] + pub context: LoopxCliGoalContext, + pub goal_id: String, + pub agent_id: String, + pub expected_durable_revision: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliBuildTurnResult { + pub goal_id: String, + pub turn_id: String, + pub prompt: String, + pub settlement_token: String, + pub durable_revision: String, + pub scheduler_hint_ms: Option, + pub deadline_at: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxCliGateDecision { + #[default] + Approve, + Reject, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliAnswerGateRequest { + #[serde(flatten)] + pub context: LoopxCliGoalContext, + pub goal_id: String, + pub agent_id: String, + pub gate_id: String, + pub decision: LoopxCliGateDecision, + pub note: Option, + pub granted_scope: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliAnswerGateResult { + pub goal_id: String, + pub gate_id: String, + pub applied: bool, + pub durable_revision: String, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxAgentTurnStatus { + #[default] + Completed, + Failed, + Cancelled, + Interrupted, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliSettleTurnRequest { + #[serde(flatten)] + pub context: LoopxCliGoalContext, + pub goal_id: String, + pub agent_id: String, + pub turn_id: String, + pub settlement_token: String, + pub expected_durable_revision: String, + pub agent_status: LoopxAgentTurnStatus, + pub agent_summary: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxCliSettlementStatus { + #[default] + Settled, + AlreadySettled, + NoDurableProgress, + RetryRequired, + GoalCompleted, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliSettleTurnResult { + pub goal_id: String, + pub turn_id: String, + pub receipt_id: String, + pub status: LoopxCliSettlementStatus, + pub before_revision: String, + pub after_revision: String, + pub validation_succeeded: bool, + pub scheduler_hint_ms: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliCancelRequest { + #[serde(flatten)] + pub call: LoopxCliCallContext, + pub target_operation_id: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliCancelResult { + pub operation_id: String, + pub target_operation_id: String, + pub cancelled: bool, +} + +/// Typed LoopX operations. No caller can pass raw CLI arguments through this port. +pub trait LoopxCliPort: Send + Sync { + fn handshake<'a>( + &'a self, + request: LoopxCliHandshakeRequest, + progress: &'a dyn LoopxCliProgressSink, + ) -> LoopxCliFuture<'a, LoopxCliManifest>; + + fn resolve_intake<'a>( + &'a self, + request: LoopxCliResolveIntakeRequest, + progress: &'a dyn LoopxCliProgressSink, + ) -> LoopxCliFuture<'a, LoopxCliResolveIntakeResult>; + + fn probe_github_auth<'a>( + &'a self, + request: LoopxGithubAuthProbeRequest, + ) -> LoopxCliFuture<'a, LoopxGithubAuthProbe>; + + fn plan_item<'a>( + &'a self, + request: LoopxCliPlanItemRequest, + progress: &'a dyn LoopxCliProgressSink, + ) -> LoopxCliFuture<'a, LoopxCliIntakePlan>; + + fn create_goal<'a>( + &'a self, + request: LoopxCliCreateGoalRequest, + progress: &'a dyn LoopxCliProgressSink, + ) -> LoopxCliFuture<'a, LoopxCliCreateGoalResult>; + + fn inspect_goal<'a>( + &'a self, + request: LoopxCliInspectGoalRequest, + progress: &'a dyn LoopxCliProgressSink, + ) -> LoopxCliFuture<'a, LoopxCliGoalSnapshot>; + + fn build_turn<'a>( + &'a self, + request: LoopxCliBuildTurnRequest, + progress: &'a dyn LoopxCliProgressSink, + ) -> LoopxCliFuture<'a, LoopxCliBuildTurnResult>; + + fn answer_gate<'a>( + &'a self, + request: LoopxCliAnswerGateRequest, + progress: &'a dyn LoopxCliProgressSink, + ) -> LoopxCliFuture<'a, LoopxCliAnswerGateResult>; + + fn settle_turn<'a>( + &'a self, + request: LoopxCliSettleTurnRequest, + progress: &'a dyn LoopxCliProgressSink, + ) -> LoopxCliFuture<'a, LoopxCliSettleTurnResult>; + + fn cancel<'a>( + &'a self, + request: LoopxCliCancelRequest, + progress: &'a dyn LoopxCliProgressSink, + ) -> LoopxCliFuture<'a, LoopxCliCancelResult>; +} + +pub type LoopxHostFuture<'a, T> = Pin> + Send + 'a>>; +pub type LoopxHostResult = Result; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxHostPortErrorKind { + #[default] + Backend, + InvalidInput, + NotFound, + Unsupported, + Conflict, + Cancelled, + Timeout, + Io, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxHostPortError { + pub kind: LoopxHostPortErrorKind, + pub message: String, + pub operation_id: Option, + pub retryable: bool, +} + +impl LoopxHostPortError { + pub fn new(kind: LoopxHostPortErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + operation_id: None, + retryable: false, + } + } +} + +impl std::fmt::Display for LoopxHostPortError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{:?}: {}", self.kind, self.message) + } +} + +impl std::error::Error for LoopxHostPortError {} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxWorkspacePrepareRequest { + pub operation_id: String, + pub task_id: String, + pub item: LoopxIssueKey, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxWorkspacePrepareResult { + pub worktree_path: String, + pub registry_path: String, + pub reused: bool, + pub repository_verified: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxWorkspaceVerifyRequest { + pub operation_id: String, + pub task_id: String, + pub item: LoopxIssueKey, + pub worktree_path: String, + pub registry_path: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxWorkspaceVerifyResult { + pub valid: bool, + pub repository: Option, + pub message: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxWorkspaceCancelRequest { + pub operation_id: String, + pub target_operation_id: String, + pub task_id: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxWorkspaceCancelResult { + pub target_operation_id: String, + pub cancelled: bool, +} + +/// Creates or reuses an isolated worktree for exactly one canonical item. +pub trait LoopxWorkspacePort: Send + Sync { + fn prepare( + &self, + request: LoopxWorkspacePrepareRequest, + ) -> LoopxHostFuture<'_, LoopxWorkspacePrepareResult>; + + fn verify( + &self, + request: LoopxWorkspaceVerifyRequest, + ) -> LoopxHostFuture<'_, LoopxWorkspaceVerifyResult>; + + fn cancel( + &self, + request: LoopxWorkspaceCancelRequest, + ) -> LoopxHostFuture<'_, LoopxWorkspaceCancelResult>; +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAgentTurnMetadata { + pub goal_id: String, + pub loopx_turn_id: String, + pub item: LoopxIssueKey, + pub attempt: u32, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAgentStartRequest { + pub operation_id: String, + pub task_id: String, + pub generation: u64, + pub worktree_path: String, + pub prompt: String, + pub model_id: String, + pub metadata: LoopxAgentTurnMetadata, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAgentStartResult { + pub session_id: String, + pub turn_id: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAgentCancelRequest { + pub operation_id: String, + pub target_operation_id: String, + pub task_id: String, + pub generation: u64, + pub session_id: String, + pub turn_id: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAgentCancelResult { + pub target_operation_id: String, + pub cancelled: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAgentFinishRequest { + pub operation_id: String, + pub task_id: String, + pub generation: u64, + pub worktree_path: String, + pub session_id: String, + pub turn_id: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAgentFinishResult { + pub session_id: String, + pub discarded: bool, +} + +/// Starts fresh transient Agent sessions bound to the prepared worktree. +pub trait LoopxAgentPort: Send + Sync { + fn start(&self, request: LoopxAgentStartRequest) -> LoopxHostFuture<'_, LoopxAgentStartResult>; + + fn cancel( + &self, + request: LoopxAgentCancelRequest, + ) -> LoopxHostFuture<'_, LoopxAgentCancelResult>; + + /// Discards the fresh transient session after terminal settlement. + fn finish( + &self, + request: LoopxAgentFinishRequest, + ) -> LoopxHostFuture<'_, LoopxAgentFinishResult>; +} diff --git a/src/crates/contracts/product-domains/src/miniapp/loopx/types.rs b/src/crates/contracts/product-domains/src/miniapp/loopx/types.rs new file mode 100644 index 0000000000..3cd9459be0 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/loopx/types.rs @@ -0,0 +1,619 @@ +//! Stable LoopX MiniApp wire types. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +pub const LOOPX_BUILTIN_APP_ID: &str = "builtin-bitfun-loopx"; +pub const LOOPX_PINNED_VERSION: &str = "0.2.13"; +pub const LOOPX_CLI_SCHEMA_VERSION: u32 = 1; + +pub type LoopxEventCursor = u64; + +#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxRepositoryKey { + pub host: String, + pub owner: String, + pub repository: String, +} + +impl LoopxRepositoryKey { + pub fn canonical_id(&self) -> String { + format!("{}/{}/{}", self.host, self.owner, self.repository) + } + + pub fn label(&self) -> String { + format!("{}/{}", self.owner, self.repository) + } +} + +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum LoopxItemKind { + #[default] + Issue, + #[serde(rename = "pr", alias = "pull_request")] + PullRequest, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxIssueKey { + pub repository: LoopxRepositoryKey, + pub kind: LoopxItemKind, + pub number: u64, +} + +impl LoopxIssueKey { + pub fn canonical_id(&self) -> String { + let collection = match self.kind { + LoopxItemKind::Issue => "issues", + LoopxItemKind::PullRequest => "pull", + }; + format!( + "{}/{}/{}", + self.repository.canonical_id(), + collection, + self.number + ) + } + + pub fn canonical_url(&self) -> String { + format!("https://{}", self.canonical_id()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "targetType", rename_all = "snake_case")] +pub enum LoopxIntakeTarget { + Repository { repository: LoopxRepositoryKey }, + Item { item: LoopxIssueKey }, +} + +impl Default for LoopxIntakeTarget { + fn default() -> Self { + Self::Repository { + repository: LoopxRepositoryKey::default(), + } + } +} + +impl LoopxIntakeTarget { + pub fn repository(&self) -> &LoopxRepositoryKey { + match self { + Self::Repository { repository } => repository, + Self::Item { item } => &item.repository, + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxRemoteItemState { + Open, + Closed, + Merged, + #[default] + #[serde(other)] + Unknown, +} + +impl LoopxRemoteItemState { + pub fn is_resolved(self) -> bool { + matches!(self, Self::Closed | Self::Merged) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxIntakeCandidate { + pub key: LoopxIssueKey, + pub url: String, + pub title: String, + pub state: LoopxRemoteItemState, + pub state_reason: Option, + pub from_repository: bool, + pub has_images: bool, + /// Repository/list intake must not silently select every candidate. + pub default_selected: bool, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxWorkspaceDisposition { + ExistingWorktree, + NewWorktree, + CloneRequired, + #[default] + Unavailable, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxWorkspacePreview { + pub disposition: LoopxWorkspaceDisposition, + pub path: Option, + pub repository_verified: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxModelCapability { + pub model_id: String, + pub available: bool, + pub supports_images: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxPermissionScope { + WorkspaceRead, + WorkspaceWrite, + GitLocal, + GithubRead, + AgentExecution, + Publish, + PublicComment, + PullRequest, + Merge, + ProductionAction, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxIntakePreview { + pub fingerprint: String, + pub target: LoopxIntakeTarget, + pub repository: LoopxRepositoryKey, + pub workspace: LoopxWorkspacePreview, + pub candidates: Vec, + pub truncated: bool, + pub model: LoopxModelCapability, + pub permission_scopes: Vec, + pub resolved_at: i64, + pub expires_at: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxEnvironmentFactStatus { + Checking, + Available, + Degraded, + Unavailable, + #[default] + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxEnvironmentFact { + pub status: LoopxEnvironmentFactStatus, + pub version: Option, + pub detail: Option, + pub remediation: Option, + pub checked_at: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCoreEnvironmentFacts { + pub sidecar: LoopxEnvironmentFact, + pub git_worktree: LoopxEnvironmentFact, + pub agent_model: LoopxEnvironmentFact, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxOptionalEnvironmentFacts { + pub python_fallback: LoopxEnvironmentFact, + pub open_viking: LoopxEnvironmentFact, + pub github_auth: LoopxEnvironmentFact, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxEnvironmentStatus { + Checking, + Ready, + Degraded, + Blocked, + #[default] + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxEnvironmentSnapshot { + pub revision: u64, + pub status: LoopxEnvironmentStatus, + pub core: LoopxCoreEnvironmentFacts, + pub optional: LoopxOptionalEnvironmentFacts, + pub checked_at: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxTaskState { + Preparing, + Queued, + Running, + WaitingForUser, + RetryWait, + Cancelling, + Stopped, + Completed, + Failed, + Archived, + #[default] + #[serde(other)] + RecoveryRequired, +} + +impl LoopxTaskState { + pub fn is_terminal(self) -> bool { + matches!( + self, + Self::Stopped | Self::Completed | Self::Failed | Self::Archived + ) + } + + pub fn was_executing_at_shutdown(self) -> bool { + matches!( + self, + Self::Preparing + | Self::Queued + | Self::Running + | Self::WaitingForUser + | Self::RetryWait + | Self::Cancelling + ) + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxPhase { + ValidatingEnvironment, + ResolvingIntake, + PreparingWorkspace, + CreatingGoal, + Queued, + InspectingGoal, + BuildingTurn, + StartingAgent, + AgentRunning, + ValidatingProgress, + SettlingTurn, + WaitingForApproval, + RetryBackoff, + Cancelling, + Recovering, + Finished, + #[default] + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxTaskIdentity { + pub item: LoopxIssueKey, + pub attempt: u32, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxSettlementSummary { + pub turn_id: Option, + pub receipt_id: Option, + pub durable_revision: Option, + pub settled_at: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxTaskSnapshot { + #[serde(alias = "id")] + pub task_id: String, + pub batch_id: Option, + pub identity: LoopxTaskIdentity, + pub generation: u64, + pub revision: u64, + pub goal_id: Option, + pub agent_id: Option, + #[serde(alias = "status")] + pub state: LoopxTaskState, + pub phase: LoopxPhase, + pub workspace_path: Option, + pub model_id: Option, + pub granted_scopes: Vec, + pub current_turn_id: Option, + pub current_tool: Option, + pub last_output_at: Option, + pub deadline_at: Option, + pub retry_at: Option, + pub error: Option, + pub settlement: LoopxSettlementSummary, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxExecutionDomain { + LocalDesktop, + RemoteWorkspace, + PeerDevice, + RemoteControl, + DetachedDispatch, + #[default] + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxExecutionSupport { + Supported, + #[default] + #[serde(other)] + UnsupportedExecutionDomain, +} + +fn default_contract_schema_version() -> u32 { + LOOPX_CLI_SCHEMA_VERSION +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxSnapshot { + #[serde(default = "default_contract_schema_version")] + pub schema_version: u32, + pub stream_id: String, + pub cursor: LoopxEventCursor, + pub revision: u64, + pub execution_domain: LoopxExecutionDomain, + pub execution_support: LoopxExecutionSupport, + pub unsupported_reason: Option, + pub environment: LoopxEnvironmentSnapshot, + pub tasks: Vec, + pub generated_at: i64, +} + +impl Default for LoopxSnapshot { + fn default() -> Self { + Self { + schema_version: default_contract_schema_version(), + stream_id: String::new(), + cursor: 0, + revision: 0, + execution_domain: LoopxExecutionDomain::default(), + execution_support: LoopxExecutionSupport::default(), + unsupported_reason: None, + environment: LoopxEnvironmentSnapshot::default(), + tasks: Vec::new(), + generated_at: 0, + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxEventLevel { + Trace, + Debug, + Warning, + Error, + #[default] + #[serde(other)] + Info, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxEventSource { + #[default] + Controller, + Sidecar, + Agent, + Git, + Github, + System, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxEventKind { + #[default] + Progress, + TaskCreated, + StateChanged, + PhaseChanged, + Log, + ApprovalRequired, + SettlementRecorded, + EnvironmentChanged, + OperationCancelled, + SnapshotInvalidated, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxEvent { + pub stream_id: String, + pub cursor: LoopxEventCursor, + pub task_id: Option, + pub generation: Option, + pub revision: Option, + pub kind: LoopxEventKind, + pub level: LoopxEventLevel, + pub source: LoopxEventSource, + pub phase: Option, + pub message: String, + pub important: bool, + pub tool_name: Option, + pub deadline_at: Option, + pub details: BTreeMap, + pub occurred_at: i64, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAttachRequest { + pub known_stream_id: Option, + pub after_cursor: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAttachResponse { + pub snapshot: LoopxSnapshot, +} + +fn default_loopx_model_id() -> String { + "auto".to_string() +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxResolveIntakeRequest { + pub input: String, + #[serde(default = "default_loopx_model_id")] + pub model_id: String, +} + +impl Default for LoopxResolveIntakeRequest { + fn default() -> Self { + Self { + input: String::new(), + model_id: default_loopx_model_id(), + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxResolveIntakeResponse { + pub preview: LoopxIntakePreview, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCreateTaskRequest { + pub client_request_id: String, + pub preview_fingerprint: String, + pub selected_items: Vec, + pub model_id: String, + pub granted_scopes: Vec, + pub retry_terminal: bool, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxCreateTaskOutcomeKind { + #[default] + Created, + OpenedExisting, + RetryConfirmationRequired, + ClosedNoop, + NeedsLiveVerification, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCreateTaskOutcome { + pub item: LoopxIssueKey, + pub kind: LoopxCreateTaskOutcomeKind, + pub task_id: Option, + pub attempt: Option, + pub message: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCreateTaskResponse { + pub outcomes: Vec, + pub snapshot_revision: u64, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxActionKind { + #[default] + Pause, + Resume, + Approve, + Reject, + Archive, + Restore, + RetryEnvironment, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxActionRequest { + pub task_id: Option, + pub action: LoopxActionKind, + pub client_request_id: String, + pub expected_revision: u64, + pub gate_id: Option, + pub note: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxActionStatus { + #[default] + Applied, + Duplicate, + RevisionConflict, + Rejected, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxActionResponse { + pub status: LoopxActionStatus, + pub current_revision: u64, + pub task: Option, + pub message: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxEventsSinceRequest { + pub stream_id: String, + pub after_cursor: LoopxEventCursor, + pub limit: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxEventsPageStatus { + #[default] + Current, + SnapshotRequired, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxEventsSinceResponse { + pub status: LoopxEventsPageStatus, + pub stream_id: String, + pub events: Vec, + pub next_cursor: LoopxEventCursor, + pub has_more: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxExistingTask { + pub task_id: String, + pub identity: LoopxTaskIdentity, + pub state: LoopxTaskState, +} diff --git a/src/crates/contracts/product-domains/src/miniapp/mod.rs b/src/crates/contracts/product-domains/src/miniapp/mod.rs index bd079c5007..c6cf628075 100644 --- a/src/crates/contracts/product-domains/src/miniapp/mod.rs +++ b/src/crates/contracts/product-domains/src/miniapp/mod.rs @@ -10,6 +10,7 @@ pub mod draft; pub mod exporter; pub mod host_routing; pub mod lifecycle; +pub mod loopx; pub mod market; pub mod permission_policy; pub mod ports; diff --git a/src/crates/contracts/product-domains/src/miniapp/runtime.rs b/src/crates/contracts/product-domains/src/miniapp/runtime.rs index 5d1e127c1e..e8e6af9f8d 100644 --- a/src/crates/contracts/product-domains/src/miniapp/runtime.rs +++ b/src/crates/contracts/product-domains/src/miniapp/runtime.rs @@ -81,10 +81,37 @@ pub fn detect_runtime_with_probe( version, }); } + // Windows: the `which`-style PATH search treats any existing file + // without an extension as an executable candidate, so an npm-style + // extensionless shim (a POSIX script that CreateProcess cannot run) + // can beat the real launcher in the same directory (e.g. `bun` vs + // `bun.cmd`). Retry the same candidate with the standard executable + // suffixes before giving up on this runtime. + #[cfg(windows)] + { + for ext in [".exe", ".cmd", ".bat", ".com"] { + let candidate = append_suffix(&path, ext); + if let Some(version) = probe.runtime_version(&candidate) { + return Some(DetectedRuntime { + kind, + path: candidate, + version, + }); + } + } + } } None } +/// Append an extension suffix to a Windows candidate path, e.g. `bun` -> `bun.cmd`. +#[cfg(windows)] +fn append_suffix(path: &Path, ext: &str) -> PathBuf { + let mut os = path.as_os_str().to_owned(); + os.push(ext); + PathBuf::from(os) +} + pub fn runtime_lookup_order() -> &'static [&'static str] { &["bun", "node"] } @@ -294,4 +321,27 @@ mod tests { assert_eq!(detected.kind, RuntimeKind::Node); assert_eq!(detected.path, node); } + + #[cfg(windows)] + #[test] + fn detector_recovers_extensionless_shim_via_pathext_suffixes() { + // npm installs a POSIX `bun` shim (no extension, not executable by + // CreateProcess) alongside `bun.cmd`; the PATH search can return the + // shim first. The detector must retry the executable suffixes and + // still pick bun over node. + let mut probe = FakeProbe::default(); + let shim = PathBuf::from(r"C:\Program Files\bun\bun"); + let real = PathBuf::from(r"C:\Program Files\bun\bun.cmd"); + let node = PathBuf::from(r"C:\Program Files\nodejs\node.exe"); + probe.path_hits.insert("bun".to_string(), shim); + probe.path_hits.insert("node".to_string(), node.clone()); + probe.versions.insert(real.clone(), "1.3.14".to_string()); + probe.versions.insert(node, "v24.0.0".to_string()); + + let detected = detect_runtime_with_probe(&probe).expect("bun should be detected"); + + assert_eq!(detected.kind, RuntimeKind::Bun); + assert_eq!(detected.path, real); + assert_eq!(detected.version, "1.3.14"); + } } diff --git a/src/crates/contracts/product-domains/tests/loopx_contracts.rs b/src/crates/contracts/product-domains/tests/loopx_contracts.rs new file mode 100644 index 0000000000..af433a5f57 --- /dev/null +++ b/src/crates/contracts/product-domains/tests/loopx_contracts.rs @@ -0,0 +1,492 @@ +#![cfg(feature = "miniapp")] + +use bitfun_product_domains::miniapp::loopx::{ + build_intake_fingerprint, decide_action_status, decide_events_page_status, decide_task_dedup, + decide_task_restart, decide_task_transition, derive_environment_status, + intake_scope_is_pregrantable, parse_loopx_intake, task_state_after_restart, LoopxActionKind, + LoopxActionRequest, LoopxActionStatus, LoopxAgentFinishRequest, LoopxAgentPort, + LoopxAgentStartRequest, LoopxCliAnswerGateRequest, LoopxCliGateDecision, + LoopxCliHandshakeRequest, LoopxCliPort, LoopxCoreEnvironmentFacts, LoopxCreateTaskRequest, + LoopxDedupDecision, LoopxEnvironmentFact, LoopxEnvironmentFactStatus, LoopxEnvironmentStatus, + LoopxEventsPageStatus, LoopxExistingTask, LoopxIntakeCandidate, LoopxIntakeParseErrorKind, + LoopxIntakeTarget, LoopxIssueKey, LoopxItemKind, LoopxOptionalEnvironmentFacts, + LoopxPermissionScope, LoopxRemoteItemState, LoopxRepositoryKey, LoopxResolveIntakeRequest, + LoopxRestartDecision, LoopxSnapshot, LoopxTaskIdentity, LoopxTaskState, + LoopxTransitionDecision, LoopxWorkspacePort, LoopxWorkspacePrepareRequest, + LOOPX_CLI_SCHEMA_VERSION, LOOPX_PINNED_VERSION, +}; + +fn issue(owner: &str, repository: &str, number: u64) -> LoopxIssueKey { + LoopxIssueKey { + repository: LoopxRepositoryKey { + host: "github.com".to_string(), + owner: owner.to_ascii_lowercase(), + repository: repository.to_ascii_lowercase(), + }, + kind: LoopxItemKind::Issue, + number, + } +} + +fn existing( + task_id: &str, + key: LoopxIssueKey, + attempt: u32, + state: LoopxTaskState, +) -> LoopxExistingTask { + LoopxExistingTask { + task_id: task_id.to_string(), + identity: LoopxTaskIdentity { item: key, attempt }, + state, + } +} + +#[test] +fn github_url_matrix_accepts_only_supported_intake_targets() { + let cases = [ + ( + "https://github.com/OpenAI/Codex/issues/123?utm_source=test#issuecomment-1", + "github.com/openai/codex/issues/123", + ), + ( + "https://www.github.com/OpenAI/Codex/pull/456/", + "github.com/openai/codex/pull/456", + ), + ("https://github.com/OpenAI/Codex", "github.com/openai/codex"), + ( + "https://github.com/OpenAI/Codex/issues?q=is%3Aopen", + "github.com/openai/codex", + ), + ("git@github.com:OpenAI/Codex.git", "github.com/openai/codex"), + ("OpenAI/Codex", "github.com/openai/codex"), + ]; + + for (input, expected) in cases { + let parsed = parse_loopx_intake(input).unwrap_or_else(|error| panic!("{input}: {error}")); + let actual = match parsed { + LoopxIntakeTarget::Repository { repository } => repository.canonical_id(), + LoopxIntakeTarget::Item { item } => item.canonical_id(), + }; + assert_eq!(actual, expected, "{input}"); + } + + let unsupported = parse_loopx_intake("https://gitlab.com/openai/codex/issues/1").unwrap_err(); + assert_eq!(unsupported.kind, LoopxIntakeParseErrorKind::UnsupportedHost); + let unsupported_path = + parse_loopx_intake("https://github.com/openai/codex/actions").unwrap_err(); + assert_eq!( + unsupported_path.kind, + LoopxIntakeParseErrorKind::UnsupportedPath + ); + let zero = parse_loopx_intake("https://github.com/openai/codex/issues/0").unwrap_err(); + assert_eq!(zero.kind, LoopxIntakeParseErrorKind::InvalidItemNumber); +} + +#[test] +fn canonical_item_identity_collapses_case_and_url_noise() { + let first = parse_loopx_intake("https://github.com/OpenAI/Codex/issues/42").unwrap(); + let second = parse_loopx_intake("http://github.com/openai/codex/issues/42?x=1").unwrap(); + assert_eq!(first, second); + + let LoopxIntakeTarget::Item { item } = first else { + panic!("expected item target"); + }; + assert_eq!(item.canonical_id(), "github.com/openai/codex/issues/42"); + assert_eq!( + item.canonical_url(), + "https://github.com/openai/codex/issues/42" + ); + + let pr = parse_loopx_intake("https://github.com/openai/codex/pull/42").unwrap(); + assert_ne!(LoopxIntakeTarget::Item { item }, pr); +} + +#[test] +fn nonterminal_duplicate_opens_the_existing_task() { + let key = issue("openai", "codex", 7); + let tasks = vec![existing( + "task-running", + key.clone(), + 1, + LoopxTaskState::Running, + )]; + + assert_eq!( + decide_task_dedup(&key, LoopxRemoteItemState::Open, &tasks, false), + LoopxDedupDecision::OpenExisting { + task_id: "task-running".to_string() + } + ); +} + +#[test] +fn terminal_duplicate_requires_an_explicit_new_attempt() { + let key = issue("openai", "codex", 8); + let tasks = vec![ + existing("attempt-1", key.clone(), 1, LoopxTaskState::Failed), + existing("attempt-2", key.clone(), 2, LoopxTaskState::Completed), + ]; + + assert_eq!( + decide_task_dedup(&key, LoopxRemoteItemState::Open, &tasks, false), + LoopxDedupDecision::RequireExplicitRetry { + previous_task_id: "attempt-2".to_string(), + next_attempt: 3, + } + ); + assert_eq!( + decide_task_dedup(&key, LoopxRemoteItemState::Open, &tasks, true), + LoopxDedupDecision::CreateAttempt { attempt: 3 } + ); +} + +#[test] +fn resolved_remote_item_is_a_successful_noop() { + let key = issue("openai", "codex", 9); + assert_eq!( + decide_task_dedup(&key, LoopxRemoteItemState::Closed, &[], false), + LoopxDedupDecision::ClosedNoop + ); + + let mut pr = key; + pr.kind = LoopxItemKind::PullRequest; + assert_eq!( + decide_task_dedup(&pr, LoopxRemoteItemState::Merged, &[], false), + LoopxDedupDecision::ClosedNoop + ); +} + +#[test] +fn restart_never_resumes_inflight_work_implicitly() { + for state in [ + LoopxTaskState::Preparing, + LoopxTaskState::Queued, + LoopxTaskState::Running, + LoopxTaskState::WaitingForUser, + LoopxTaskState::RetryWait, + LoopxTaskState::Cancelling, + ] { + assert_eq!( + decide_task_restart(state), + LoopxRestartDecision::RequireRecovery + ); + assert_eq!( + task_state_after_restart(state), + LoopxTaskState::RecoveryRequired + ); + } + + for state in [ + LoopxTaskState::RecoveryRequired, + LoopxTaskState::Stopped, + LoopxTaskState::Completed, + LoopxTaskState::Failed, + LoopxTaskState::Archived, + ] { + assert_eq!( + decide_task_restart(state), + LoopxRestartDecision::Preserve { state } + ); + assert_eq!(task_state_after_restart(state), state); + } +} + +#[test] +fn transition_policy_separates_turn_completion_from_task_completion() { + assert_eq!( + decide_task_transition(LoopxTaskState::Running, LoopxTaskState::Queued), + LoopxTransitionDecision::Allowed { + next: LoopxTaskState::Queued + } + ); + assert_eq!( + decide_task_transition(LoopxTaskState::Running, LoopxTaskState::Completed), + LoopxTransitionDecision::Allowed { + next: LoopxTaskState::Completed + } + ); + assert_eq!( + decide_task_transition(LoopxTaskState::Completed, LoopxTaskState::Running), + LoopxTransitionDecision::Rejected + ); +} + +#[test] +fn additive_snapshot_fields_deserialize_with_safe_legacy_defaults() { + let snapshot: LoopxSnapshot = serde_json::from_value(serde_json::json!({ + "streamId": "legacy-stream", + "tasks": [{ + "id": "legacy-task", + "status": "legacy_active", + "identity": { + "item": { + "repository": { + "host": "github.com", + "owner": "openai", + "repository": "codex" + }, + "kind": "issue", + "number": 10 + }, + "attempt": 1 + } + }] + })) + .unwrap(); + + assert_eq!(snapshot.schema_version, LOOPX_CLI_SCHEMA_VERSION); + assert_eq!(snapshot.tasks[0].state, LoopxTaskState::RecoveryRequired); + assert_eq!(snapshot.tasks[0].task_id, "legacy-task"); + assert_eq!( + snapshot.environment.core.sidecar.status, + LoopxEnvironmentFactStatus::Unknown + ); + assert_eq!( + snapshot.execution_support.to_string(), + "unsupported_execution_domain" + ); + + let encoded = serde_json::to_value(&snapshot).unwrap(); + assert_eq!(encoded["tasks"][0]["state"], "recovery_required"); + assert_eq!(encoded["executionSupport"], "unsupported_execution_domain"); +} + +#[test] +fn action_and_create_requests_use_idempotency_and_revision_fields() { + let resolve: LoopxResolveIntakeRequest = serde_json::from_value(serde_json::json!({ + "input": "https://github.com/openai/codex/issues/1" + })) + .unwrap(); + assert_eq!(resolve.model_id, "auto"); + + let action: LoopxActionRequest = serde_json::from_value(serde_json::json!({ + "taskId": "task-1", + "action": "retry_environment", + "clientRequestId": "request-1", + "expectedRevision": 17 + })) + .unwrap(); + assert_eq!(action.action, LoopxActionKind::RetryEnvironment); + assert_eq!(action.client_request_id, "request-1"); + assert_eq!(action.expected_revision, 17); + + let create: LoopxCreateTaskRequest = serde_json::from_value(serde_json::json!({ + "clientRequestId": "request-2", + "previewFingerprint": "sha256:abc", + "selectedItems": [], + "modelId": "primary", + "grantedScopes": ["workspace_read"], + "retryTerminal": false + })) + .unwrap(); + assert_eq!(create.client_request_id, "request-2"); + assert_eq!( + create.granted_scopes, + vec![LoopxPermissionScope::WorkspaceRead] + ); + + let page_status = serde_json::to_value(LoopxEventsPageStatus::SnapshotRequired).unwrap(); + assert_eq!(page_status, "snapshot_required"); +} + +#[test] +fn intake_fingerprint_is_order_independent_but_state_sensitive() { + let target = parse_loopx_intake("https://github.com/openai/codex/issues").unwrap(); + let candidate = |number, state| LoopxIntakeCandidate { + key: issue("openai", "codex", number), + url: format!("https://github.com/openai/codex/issues/{number}"), + title: format!("Issue {number}"), + state, + ..LoopxIntakeCandidate::default() + }; + let first = vec![ + candidate(1, LoopxRemoteItemState::Open), + candidate(2, LoopxRemoteItemState::Open), + ]; + let reversed = vec![first[1].clone(), first[0].clone()]; + let scopes = [ + LoopxPermissionScope::AgentExecution, + LoopxPermissionScope::WorkspaceWrite, + ]; + + let fingerprint = + build_intake_fingerprint(&target, &first, Some("/work/codex"), "primary", &scopes); + assert_eq!( + fingerprint, + build_intake_fingerprint(&target, &reversed, Some("/work/codex"), "primary", &scopes) + ); + + let changed = vec![ + candidate(1, LoopxRemoteItemState::Closed), + candidate(2, LoopxRemoteItemState::Open), + ]; + assert_ne!( + fingerprint, + build_intake_fingerprint(&target, &changed, Some("/work/codex"), "primary", &scopes) + ); +} + +#[test] +fn handshake_defaults_pin_the_supported_loopx_contract() { + let request: LoopxCliHandshakeRequest = serde_json::from_value(serde_json::json!({ + "operationId": "probe-1" + })) + .unwrap(); + assert_eq!(request.required_loopx_version, LOOPX_PINNED_VERSION); + assert_eq!(request.required_schema_version, LOOPX_CLI_SCHEMA_VERSION); + + fn assert_object_safe(_: &dyn LoopxCliPort) {} + let _ = assert_object_safe; +} + +#[test] +fn workspace_agent_and_gate_ports_keep_routes_typed() { + let key = issue("openai", "codex", 11); + let workspace = LoopxWorkspacePrepareRequest { + operation_id: "workspace-1".to_string(), + task_id: "task-1".to_string(), + item: key.clone(), + }; + let workspace_json = serde_json::to_value(workspace).unwrap(); + assert_eq!(workspace_json["operationId"], "workspace-1"); + assert_eq!(workspace_json["item"]["number"], 11); + assert!(workspace_json.get("worktreePath").is_none()); + + let agent = LoopxAgentStartRequest { + operation_id: "agent-1".to_string(), + task_id: "task-1".to_string(), + generation: 3, + worktree_path: "/worktrees/task-1".to_string(), + prompt: "Fix the selected issue".to_string(), + model_id: "primary".to_string(), + metadata: Default::default(), + }; + let agent_json = serde_json::to_value(agent).unwrap(); + assert_eq!(agent_json["generation"], 3); + assert_eq!(agent_json["worktreePath"], "/worktrees/task-1"); + + let finish = LoopxAgentFinishRequest { + operation_id: "finish-1".to_string(), + task_id: "task-1".to_string(), + generation: 3, + worktree_path: "/worktrees/task-1".to_string(), + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + }; + let finish_json = serde_json::to_value(finish).unwrap(); + assert_eq!(finish_json["worktreePath"], "/worktrees/task-1"); + assert_eq!(finish_json["sessionId"], "session-1"); + assert_eq!(finish_json["turnId"], "turn-1"); + let finish_roundtrip: LoopxAgentFinishRequest = serde_json::from_value(finish_json).unwrap(); + assert_eq!(finish_roundtrip.worktree_path, "/worktrees/task-1"); + assert_eq!(LoopxAgentFinishRequest::default().worktree_path, ""); + + let gate: LoopxCliAnswerGateRequest = serde_json::from_value(serde_json::json!({ + "operationId": "gate-1", + "taskId": "task-1", + "generation": 3, + "worktreePath": "/worktrees/task-1", + "registryPath": "/worktrees/task-1/.loopx/registry.json", + "goalId": "goal-1", + "agentId": "agent-1", + "gateId": "gate-publish", + "decision": "reject", + "note": "Do not publish", + "grantedScope": null + })) + .unwrap(); + assert_eq!(gate.decision, LoopxCliGateDecision::Reject); + assert_eq!(gate.agent_id, "agent-1"); + assert_eq!(gate.gate_id, "gate-publish"); + + fn assert_workspace_object_safe(_: &dyn LoopxWorkspacePort) {} + fn assert_agent_object_safe(_: &dyn LoopxAgentPort) {} + let _ = (assert_workspace_object_safe, assert_agent_object_safe); +} + +#[test] +fn optional_environment_failures_degrade_without_blocking_core_readiness() { + let available = LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Available, + ..LoopxEnvironmentFact::default() + }; + let core = LoopxCoreEnvironmentFacts { + sidecar: available.clone(), + git_worktree: available.clone(), + agent_model: available, + }; + let optional = LoopxOptionalEnvironmentFacts { + python_fallback: LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Unavailable, + ..LoopxEnvironmentFact::default() + }, + ..LoopxOptionalEnvironmentFacts::default() + }; + assert_eq!( + derive_environment_status(&core, &optional), + LoopxEnvironmentStatus::Degraded + ); + + let mut blocked_core = core; + blocked_core.sidecar.status = LoopxEnvironmentFactStatus::Unavailable; + assert_eq!( + derive_environment_status(&blocked_core, &optional), + LoopxEnvironmentStatus::Blocked + ); + assert!(intake_scope_is_pregrantable( + LoopxPermissionScope::WorkspaceWrite + )); + assert!(!intake_scope_is_pregrantable( + LoopxPermissionScope::PullRequest + )); +} + +#[test] +fn action_idempotency_precedes_revision_conflict() { + assert_eq!( + decide_action_status(true, 4, 5), + LoopxActionStatus::Duplicate + ); + assert_eq!( + decide_action_status(false, 4, 5), + LoopxActionStatus::RevisionConflict + ); + assert_eq!( + decide_action_status(false, 5, 5), + LoopxActionStatus::Applied + ); +} + +#[test] +fn event_cursor_gaps_and_stream_changes_require_a_snapshot() { + assert_eq!( + decide_events_page_status("stream-2", "stream-1", 10, Some(5), 20), + LoopxEventsPageStatus::SnapshotRequired + ); + assert_eq!( + decide_events_page_status("stream-2", "stream-2", 3, Some(5), 20), + LoopxEventsPageStatus::SnapshotRequired + ); + assert_eq!( + decide_events_page_status("stream-2", "stream-2", 4, Some(5), 20), + LoopxEventsPageStatus::Current + ); + assert_eq!( + decide_events_page_status("stream-2", "stream-2", 21, Some(5), 20), + LoopxEventsPageStatus::SnapshotRequired + ); +} + +trait EnumString { + fn to_string(self) -> String; +} + +impl EnumString for bitfun_product_domains::miniapp::loopx::LoopxExecutionSupport { + fn to_string(self) -> String { + serde_json::to_value(self) + .unwrap() + .as_str() + .unwrap() + .to_string() + } +} diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index fc1eb3705c..db31aa20c3 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -195,6 +195,28 @@ miniapp-runtime = [ "uuid", "which", ] +miniapp-loopx = [ + "async-trait", + "bitfun-product-domains/miniapp", + "bitfun-services-core/process-runtime", + "dep:bitfun-product-domains", + "hex", + "reqwest", + "reqwest/json", + "reqwest/rustls", + "sha2", + "thiserror", + "tokio/fs", + "tokio/io-util", + "tokio/macros", + "tokio/process", + "tokio/rt", + "tokio/sync", + "tokio/time", + "tokio-util", + "url", + "which", +] miniapp-market = [ "bitfun-product-domains/appearance-market", "chrono", @@ -414,6 +436,7 @@ product-full = [ "function-agents", "git", "hook-import", + "miniapp-loopx", "miniapp-runtime", "mcp", "plugin-source", @@ -471,6 +494,11 @@ name = "mcp_streamable_http_contracts" path = "tests/mcp_streamable_http_contracts.rs" required-features = ["mcp"] +[[test]] +name = "miniapp_loopx_contracts" +path = "tests/miniapp_loopx_contracts.rs" +required-features = ["miniapp-loopx"] + [[test]] name = "remote_connect_contracts" path = "tests/remote_connect_contracts.rs" diff --git a/src/crates/services/services-integrations/src/lib.rs b/src/crates/services/services-integrations/src/lib.rs index 68b2221d35..5146769188 100644 --- a/src/crates/services/services-integrations/src/lib.rs +++ b/src/crates/services/services-integrations/src/lib.rs @@ -42,6 +42,9 @@ pub mod models_dev; #[cfg(feature = "miniapp-runtime")] pub mod miniapp; +#[cfg(all(feature = "miniapp-loopx", not(feature = "miniapp-runtime")))] +pub mod miniapp; + #[cfg(feature = "miniapp-market")] pub mod miniapp_market; diff --git a/src/crates/services/services-integrations/src/miniapp/host_dispatch.rs b/src/crates/services/services-integrations/src/miniapp/host_dispatch.rs index b05152b9b6..ae9c8bd9b6 100644 --- a/src/crates/services/services-integrations/src/miniapp/host_dispatch.rs +++ b/src/crates/services/services-integrations/src/miniapp/host_dispatch.rs @@ -4,7 +4,7 @@ //! Why this exists //! --------------- //! The original MiniApp design routed every `app.*` call through a Bun/Node Worker -//! (`resources/worker_host.js`). That gives apps a real V8 sandbox for arbitrary +//! (`resources/worker_host.cjs`). That gives apps a real V8 sandbox for arbitrary //! `worker.js` code, but it forces every app — even ones that just want to shell out //! to `git` — to depend on having Bun or Node installed and a worker runtime online. //! @@ -19,7 +19,7 @@ //! pool when the app has `node.enabled = true`. `storage.*` is served by the manager //! directly from the Tauri command layer regardless of node.enabled. //! -//! Permission enforcement here mirrors `worker_host.js` exactly so the security +//! Permission enforcement here mirrors `worker_host.cjs` exactly so the security //! contract is identical regardless of the routing path. use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; @@ -105,7 +105,7 @@ pub type MiniAppHostDispatchResult = Result; /// Dispatch a framework-primitive RPC on the host. /// /// `perms` and the path arguments are used to build a permission policy with the -/// same shape `worker_host.js` consumes, then the namespace-specific handler is +/// same shape `worker_host.cjs` consumes, then the namespace-specific handler is /// invoked. pub async fn dispatch_host( perms: &MiniAppPermissions, @@ -169,7 +169,7 @@ fn canonicalize_best_effort(p: &Path) -> PathBuf { } /// A target path is allowed when its canonicalized form starts with one of the -/// canonicalized scope roots. Mirrors the worker_host.js check, but uses real +/// canonicalized scope roots. Mirrors the worker_host.cjs check, but uses real /// canonicalization so e.g. `/tmp/foo` on macOS (`/private/tmp/foo`) matches a /// `/tmp` scope after both sides resolve symlinks. fn path_allowed(policy: &Value, target: &Path, mode: FsAccessMode) -> bool { @@ -410,7 +410,7 @@ async fn dispatch_shell( } }; cmd.current_dir(&plan.cwd); - // Match worker_host.js: never let git prompt for credentials, force C locale so + // Match worker_host.cjs: never let git prompt for credentials, force C locale so // stdout parsing is deterministic. for (key, value) in shell_exec_default_env() { cmd.env(key, value); @@ -433,7 +433,7 @@ async fn dispatch_shell( let code = output.status.code().unwrap_or(-1); if !output.status.success() { - // Mirror worker_host.js (which uses Node `execAsync`, rejecting on non-zero + // Mirror worker_host.cjs (which uses Node `execAsync`, rejecting on non-zero // exit with stderr in the message). let msg = if !stderr.trim().is_empty() { stderr.trim().to_string() diff --git a/src/crates/services/services-integrations/src/miniapp/loopx_cli.rs b/src/crates/services/services-integrations/src/miniapp/loopx_cli.rs new file mode 100644 index 0000000000..9fce8a5a6a --- /dev/null +++ b/src/crates/services/services-integrations/src/miniapp/loopx_cli.rs @@ -0,0 +1,2265 @@ +//! Managed LoopX CLI integration for the built-in LoopX MiniApp. +//! +//! The product-facing adapter below never accepts an executable or an argv +//! prefix from callers. It selects the packaged binary first and only permits +//! the fixed `loopx` system command when that fallback was explicitly enabled. + +use async_trait::async_trait; +use bitfun_product_domains::miniapp::loopx as loopx_contract; +use bitfun_services_core::process_tree::ProcessTreeChild; +use serde::Deserialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, HashMap, VecDeque}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::{Arc, Mutex as StdMutex}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use thiserror::Error; +use tokio::io::{AsyncRead, AsyncReadExt}; +use tokio::process::Command; +use tokio::sync::{mpsc, Mutex}; +use tokio_util::sync::CancellationToken; + +pub const LOOPX_PINNED_VERSION: &str = "0.2.13"; +pub const LOOPX_PINNED_VERSION_TAG: &str = "v0.2.13"; +pub const LOOPX_PINNED_VERSION_OUTPUT: &str = "loopx 0.2.13"; +pub const LOOPX_BUNDLE_MANIFEST_SCHEMA: u32 = 1; +pub const LOOPX_COMMAND_REFERENCE_SCHEMA: &str = "loopx_command_reference_v0"; + +const MAX_STDOUT_BYTES: usize = 8 * 1024 * 1024; +const MAX_STDERR_TAIL_BYTES: usize = 32 * 1024; +const MAX_PROGRESS_LINE_BYTES: usize = 4 * 1024; +const PIPE_DRAIN_DEADLINE: Duration = Duration::from_millis(250); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoopxSystemFallbackPolicy { + Disabled, + ExactPinned, +} + +#[derive(Debug, Clone)] +pub struct LoopxCliAdapterConfig { + pub resource_dir: PathBuf, + pub system_fallback: LoopxSystemFallbackPolicy, + pub startup_deadline: Duration, + pub command_deadline: Duration, + pub terminate_grace: Duration, +} + +impl LoopxCliAdapterConfig { + pub fn packaged(resource_dir: impl Into) -> Self { + Self { + resource_dir: resource_dir.into(), + system_fallback: LoopxSystemFallbackPolicy::Disabled, + startup_deadline: Duration::from_secs(60), + command_deadline: Duration::from_secs(180), + terminate_grace: Duration::from_secs(2), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoopxCommandSource { + PackagedBundle, + FixedSystemCommand, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedLoopxCommand { + pub executable: PathBuf, + pub source: LoopxCommandSource, + pub version: String, + pub bundle_manifest_schema: Option, + pub command_reference_schema: String, + pub sha256: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoopxCommandPlan { + pub operation_id: String, + pub executable: PathBuf, + pub args: Vec, + pub current_dir: Option, + pub environment: BTreeMap, + pub deadline: Duration, + pub terminate_grace: Duration, +} + +impl LoopxCommandPlan { + fn handshake( + operation_id: impl Into, + executable: PathBuf, + args: impl IntoIterator>, + deadline: Duration, + terminate_grace: Duration, + ) -> Self { + Self { + operation_id: operation_id.into(), + executable, + args: args.into_iter().map(Into::into).collect(), + current_dir: None, + environment: BTreeMap::new(), + deadline, + terminate_grace, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoopxProgressStage { + Starting, + Stderr, + Exited, + Cancelling, + TimedOut, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoopxProcessProgress { + pub operation_id: String, + pub stage: LoopxProgressStage, + pub message: String, + pub occurred_at_unix_ms: u64, +} + +pub trait LoopxProcessObserver: Send + Sync { + fn on_progress(&self, progress: LoopxProcessProgress); +} + +#[derive(Debug, Default)] +pub struct NoopLoopxProcessObserver; + +impl LoopxProcessObserver for NoopLoopxProcessObserver { + fn on_progress(&self, _progress: LoopxProcessProgress) {} +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoopxProcessOutput { + pub stdout: String, + pub stderr_tail: Vec, + pub elapsed: Duration, +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum LoopxProcessError { + #[error("failed to start LoopX process: {message}")] + Start { message: String }, + #[error("LoopX process IO failed: {message}")] + Io { message: String }, + #[error("LoopX process exited with status {code:?}")] + Exited { + code: Option, + stderr_tail: Vec, + }, + #[error("LoopX process timed out after {deadline_ms} ms")] + Timeout { + deadline_ms: u64, + stderr_tail: Vec, + }, + #[error("LoopX process was cancelled")] + Cancelled { stderr_tail: Vec }, + #[error("LoopX stdout exceeded the {limit_bytes}-byte limit")] + OutputLimit { limit_bytes: usize }, +} + +#[async_trait] +pub trait LoopxProcessRunner: Send + Sync { + async fn run( + &self, + plan: LoopxCommandPlan, + cancellation: CancellationToken, + observer: &dyn LoopxProcessObserver, + ) -> Result; +} + +#[derive(Debug, Default)] +pub struct SystemLoopxProcessRunner; + +#[async_trait] +impl LoopxProcessRunner for SystemLoopxProcessRunner { + async fn run( + &self, + plan: LoopxCommandPlan, + cancellation: CancellationToken, + observer: &dyn LoopxProcessObserver, + ) -> Result { + emit_progress( + observer, + &plan.operation_id, + LoopxProgressStage::Starting, + "Starting LoopX process", + ); + + let started = Instant::now(); + let mut command = Command::new(&plan.executable); + command + .args(&plan.args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(current_dir) = &plan.current_dir { + command.current_dir(current_dir); + } + if !plan.environment.is_empty() { + command.envs(&plan.environment); + } + + let mut child = ProcessTreeChild::spawn(&mut command) + .await + .map_err(|error| LoopxProcessError::Start { + message: error.to_string(), + })?; + let stdout = child.take_stdout().ok_or_else(|| LoopxProcessError::Io { + message: "LoopX stdout pipe was not available".to_string(), + })?; + let stderr = child.take_stderr().ok_or_else(|| LoopxProcessError::Io { + message: "LoopX stderr pipe was not available".to_string(), + })?; + + let mut stdout_task = tokio::spawn(capture_stdout(stdout)); + let (stderr_line_tx, mut stderr_line_rx) = mpsc::channel(128); + let mut stderr_task = + tokio::spawn(async move { capture_stderr(stderr, stderr_line_tx).await }); + + enum Completion { + Exited(std::io::Result), + Cancelled, + TimedOut, + } + + let deadline = tokio::time::sleep(plan.deadline); + tokio::pin!(deadline); + let completion = loop { + tokio::select! { + biased; + _ = cancellation.cancelled() => break Completion::Cancelled, + _ = &mut deadline => break Completion::TimedOut, + status = child.wait() => break Completion::Exited(status), + line = stderr_line_rx.recv() => { + if let Some(line) = line { + emit_progress( + observer, + &plan.operation_id, + LoopxProgressStage::Stderr, + &line, + ); + } + } + } + }; + while let Ok(line) = stderr_line_rx.try_recv() { + emit_progress( + observer, + &plan.operation_id, + LoopxProgressStage::Stderr, + &line, + ); + } + + match completion { + Completion::Cancelled => { + emit_progress( + observer, + &plan.operation_id, + LoopxProgressStage::Cancelling, + "Cancelling LoopX process tree", + ); + let _ = child.terminate(plan.terminate_grace).await; + let stderr_tail = drain_stderr_task(&mut stderr_task).await; + stdout_task.abort(); + Err(LoopxProcessError::Cancelled { stderr_tail }) + } + Completion::TimedOut => { + emit_progress( + observer, + &plan.operation_id, + LoopxProgressStage::TimedOut, + "LoopX process deadline expired", + ); + let _ = child.terminate(plan.terminate_grace).await; + let stderr_tail = drain_stderr_task(&mut stderr_task).await; + stdout_task.abort(); + Err(LoopxProcessError::Timeout { + deadline_ms: duration_millis(plan.deadline), + stderr_tail, + }) + } + Completion::Exited(status) => { + let status = status.map_err(|error| LoopxProcessError::Io { + message: error.to_string(), + })?; + let stdout_capture = drain_stdout_task(&mut stdout_task).await?; + let stderr_tail = drain_stderr_task(&mut stderr_task).await; + emit_progress( + observer, + &plan.operation_id, + LoopxProgressStage::Exited, + if status.success() { + "LoopX process exited successfully" + } else { + "LoopX process exited with an error" + }, + ); + if !status.success() { + return Err(LoopxProcessError::Exited { + code: status.code(), + stderr_tail, + }); + } + if stdout_capture.exceeded_limit { + return Err(LoopxProcessError::OutputLimit { + limit_bytes: MAX_STDOUT_BYTES, + }); + } + Ok(LoopxProcessOutput { + stdout: String::from_utf8_lossy(&stdout_capture.bytes).into_owned(), + stderr_tail, + elapsed: started.elapsed(), + }) + } + } + } +} + +pub trait LoopxFixedCommandLocator: Send + Sync { + fn locate(&self) -> Result, String>; +} + +#[async_trait] +pub trait LoopxIntakeMetadataProvider: Send + Sync { + async fn resolve( + &self, + request: &loopx_contract::LoopxCliResolveIntakeRequest, + deadline: Duration, + ) -> loopx_contract::LoopxCliResult; + + /// Pre-flight GitHub access probe used to populate the `github_auth` + /// environment fact before any intake is submitted. + async fn probe_auth( + &self, + deadline: Duration, + ) -> loopx_contract::LoopxCliResult; +} + +#[derive(Debug, Default)] +pub struct UnsupportedLoopxIntakeMetadataProvider; + +#[async_trait] +impl LoopxIntakeMetadataProvider for UnsupportedLoopxIntakeMetadataProvider { + async fn resolve( + &self, + request: &loopx_contract::LoopxCliResolveIntakeRequest, + _deadline: Duration, + ) -> loopx_contract::LoopxCliResult { + Err(loopx_contract::LoopxCliError::new( + loopx_contract::LoopxCliErrorKind::NotFound, + "LoopX intake metadata provider is not configured", + ) + .for_operation(&request.call.operation_id) + .retryable(true)) + } + + async fn probe_auth( + &self, + _deadline: Duration, + ) -> loopx_contract::LoopxCliResult { + Ok(loopx_contract::LoopxGithubAuthProbe { + authenticated: false, + detail: Some("GitHub intake metadata provider is not configured".to_string()), + ..loopx_contract::LoopxGithubAuthProbe::default() + }) + } +} + +#[derive(Debug, Default)] +pub struct SystemLoopxFixedCommandLocator; + +impl LoopxFixedCommandLocator for SystemLoopxFixedCommandLocator { + fn locate(&self) -> Result, String> { + match which::which("loopx") { + Ok(path) => Ok(Some(path)), + Err(which::Error::CannotFindBinaryPath) => Ok(None), + Err(error) => Err(error.to_string()), + } + } +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum LoopxCliAdapterError { + #[error("packaged LoopX bundle is not available")] + Unavailable, + #[error("invalid packaged LoopX manifest: {message}")] + Manifest { message: String }, + #[error("LoopX version mismatch: expected {expected}, got {actual}")] + VersionMismatch { expected: String, actual: String }, + #[error("LoopX schema mismatch: expected {expected}, got {actual}")] + SchemaMismatch { expected: String, actual: String }, + #[error("LoopX operation id is already running: {operation_id}")] + Conflict { operation_id: String }, + #[error("LoopX returned invalid JSON: {message}")] + InvalidJson { message: String }, + #[error(transparent)] + Process(#[from] LoopxProcessError), +} + +#[derive(Debug, Clone)] +pub struct LoopxJsonOutput { + pub payload: Value, + pub stderr_tail: Vec, + pub elapsed: Duration, +} + +pub struct LoopxCliProcessAdapter { + config: LoopxCliAdapterConfig, + runner: Arc, + locator: Arc, + observer: Arc, + intake_metadata: Arc, + intake_metadata_configured: bool, + verified: Mutex>, + running: Arc>>, +} + +impl std::fmt::Debug for LoopxCliProcessAdapter { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("LoopxCliProcessAdapter") + .field("config", &self.config) + .finish_non_exhaustive() + } +} + +impl LoopxCliProcessAdapter { + pub fn new(config: LoopxCliAdapterConfig) -> Self { + Self::with_dependencies( + config, + Arc::new(SystemLoopxProcessRunner), + Arc::new(SystemLoopxFixedCommandLocator), + Arc::new(NoopLoopxProcessObserver), + ) + } + + pub fn with_dependencies( + config: LoopxCliAdapterConfig, + runner: Arc, + locator: Arc, + observer: Arc, + ) -> Self { + Self { + config, + runner, + locator, + observer, + intake_metadata: Arc::new(UnsupportedLoopxIntakeMetadataProvider), + intake_metadata_configured: false, + verified: Mutex::new(None), + running: Arc::new(StdMutex::new(HashMap::new())), + } + } + + pub fn with_intake_metadata_provider( + mut self, + provider: Arc, + ) -> Self { + self.intake_metadata = provider; + self.intake_metadata_configured = true; + self + } + + pub async fn verify_handshake( + &self, + operation_id: &str, + ) -> Result { + let (cancellation, _registration) = self.register_operation(operation_id)?; + self.ensure_verified( + operation_id, + cancellation, + self.config.startup_deadline, + self.observer.as_ref(), + ) + .await + } + + pub fn cancel_operation(&self, operation_id: &str) -> bool { + let running = self + .running + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + if let Some(cancellation) = running.get(operation_id) { + cancellation.cancel(); + true + } else { + false + } + } + + async fn run_json_command( + &self, + operation_id: &str, + registry_path: &Path, + current_dir: Option<&Path>, + command_args: Vec, + deadline: Duration, + observer: &dyn LoopxProcessObserver, + ) -> Result { + let (cancellation, _registration) = self.register_operation(operation_id)?; + let verified = self + .ensure_verified( + operation_id, + cancellation.clone(), + deadline.min(self.config.startup_deadline), + observer, + ) + .await?; + let mut args = vec![ + OsString::from("--format"), + OsString::from("json"), + OsString::from("--registry"), + registry_path.as_os_str().to_owned(), + ]; + args.extend(command_args); + let output = self + .runner + .run( + LoopxCommandPlan { + operation_id: operation_id.to_string(), + executable: verified.executable, + args, + current_dir: current_dir.map(Path::to_path_buf), + environment: BTreeMap::new(), + deadline, + terminate_grace: self.config.terminate_grace, + }, + cancellation, + observer, + ) + .await?; + let payload = serde_json::from_str(&output.stdout).map_err(|error| { + LoopxCliAdapterError::InvalidJson { + message: error.to_string(), + } + })?; + Ok(LoopxJsonOutput { + payload, + stderr_tail: output.stderr_tail, + elapsed: output.elapsed, + }) + } + + async fn ensure_verified( + &self, + operation_id: &str, + cancellation: CancellationToken, + startup_deadline: Duration, + observer: &dyn LoopxProcessObserver, + ) -> Result { + let mut verified_guard = self.verified.lock().await; + if let Some(verified) = verified_guard.as_ref() { + return Ok(verified.clone()); + } + + let candidate = self.select_candidate().await?; + let version_output = self + .runner + .run( + LoopxCommandPlan::handshake( + operation_id, + candidate.executable.clone(), + ["--version"], + startup_deadline, + self.config.terminate_grace, + ), + cancellation.clone(), + observer, + ) + .await?; + let actual_version = version_output.stdout.trim(); + if actual_version != LOOPX_PINNED_VERSION_OUTPUT { + return Err(LoopxCliAdapterError::VersionMismatch { + expected: LOOPX_PINNED_VERSION_OUTPUT.to_string(), + actual: actual_version.to_string(), + }); + } + + let schema_output = self + .runner + .run( + LoopxCommandPlan::handshake( + operation_id, + candidate.executable.clone(), + ["--format", "json", "commands"], + startup_deadline, + self.config.terminate_grace, + ), + cancellation, + observer, + ) + .await?; + let schema_payload: Value = + serde_json::from_str(&schema_output.stdout).map_err(|error| { + LoopxCliAdapterError::InvalidJson { + message: error.to_string(), + } + })?; + let actual_schema = schema_payload + .get("schema_version") + .and_then(Value::as_str) + .unwrap_or_default(); + if schema_payload.get("ok").and_then(Value::as_bool) != Some(true) + || actual_schema != LOOPX_COMMAND_REFERENCE_SCHEMA + { + return Err(LoopxCliAdapterError::SchemaMismatch { + expected: LOOPX_COMMAND_REFERENCE_SCHEMA.to_string(), + actual: actual_schema.to_string(), + }); + } + + let verified = VerifiedLoopxCommand { + executable: candidate.executable, + source: candidate.source, + version: LOOPX_PINNED_VERSION.to_string(), + bundle_manifest_schema: candidate.bundle_manifest_schema, + command_reference_schema: LOOPX_COMMAND_REFERENCE_SCHEMA.to_string(), + sha256: candidate.sha256, + }; + *verified_guard = Some(verified.clone()); + Ok(verified) + } + + async fn select_candidate(&self) -> Result { + let bundle_dir = self.config.resource_dir.join("loopx"); + let executable = bundle_dir.join(if cfg!(windows) { "loopx.exe" } else { "loopx" }); + let manifest_path = bundle_dir.join("manifest.json"); + let executable_exists = tokio::fs::try_exists(&executable).await.map_err(|error| { + LoopxCliAdapterError::Manifest { + message: error.to_string(), + } + })?; + let manifest_exists = tokio::fs::try_exists(&manifest_path) + .await + .map_err(|error| LoopxCliAdapterError::Manifest { + message: error.to_string(), + })?; + + if executable_exists || manifest_exists { + if !executable_exists || !manifest_exists { + return Err(LoopxCliAdapterError::Manifest { + message: "bundle must contain both the executable and manifest.json" + .to_string(), + }); + } + let raw = tokio::fs::read(&manifest_path).await.map_err(|error| { + LoopxCliAdapterError::Manifest { + message: error.to_string(), + } + })?; + let manifest: BundledLoopxManifest = + serde_json::from_slice(&raw).map_err(|error| LoopxCliAdapterError::Manifest { + message: error.to_string(), + })?; + verify_manifest(&manifest)?; + let digest = sha256_file(&executable).await?; + let expected_digest = manifest + .sha256 + .strip_prefix("sha256:") + .unwrap_or(&manifest.sha256); + if digest != expected_digest { + return Err(LoopxCliAdapterError::Manifest { + message: "bundle executable checksum does not match manifest.json".to_string(), + }); + } + return Ok(LoopxCandidate { + executable, + source: LoopxCommandSource::PackagedBundle, + bundle_manifest_schema: Some(manifest.schema_version), + sha256: Some(digest), + }); + } + + if self.config.system_fallback == LoopxSystemFallbackPolicy::ExactPinned { + let located = self + .locator + .locate() + .map_err(|message| LoopxCliAdapterError::Manifest { message })?; + if let Some(executable) = located { + return Ok(LoopxCandidate { + executable, + source: LoopxCommandSource::FixedSystemCommand, + bundle_manifest_schema: None, + sha256: None, + }); + } + } + Err(LoopxCliAdapterError::Unavailable) + } + + fn register_operation( + &self, + operation_id: &str, + ) -> Result<(CancellationToken, OperationRegistration), LoopxCliAdapterError> { + let mut running = self + .running + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + if running.contains_key(operation_id) { + return Err(LoopxCliAdapterError::Conflict { + operation_id: operation_id.to_string(), + }); + } + let cancellation = CancellationToken::new(); + running.insert(operation_id.to_string(), cancellation.clone()); + Ok(( + cancellation, + OperationRegistration { + operation_id: operation_id.to_string(), + running: self.running.clone(), + }, + )) + } +} + +struct PortProcessObserver<'a> { + progress: &'a dyn loopx_contract::LoopxCliProgressSink, + fallback: &'a dyn LoopxProcessObserver, + task_id: Option, + stage: loopx_contract::LoopxCliProgressStage, +} + +impl LoopxProcessObserver for PortProcessObserver<'_> { + fn on_progress(&self, progress: LoopxProcessProgress) { + self.fallback.on_progress(progress.clone()); + self.progress.report(loopx_contract::LoopxCliProgress { + operation_id: progress.operation_id, + task_id: self.task_id.clone(), + stage: self.stage, + message: progress.message, + occurred_at: progress.occurred_at_unix_ms.try_into().unwrap_or(i64::MAX), + }); + } +} + +impl loopx_contract::LoopxCliPort for LoopxCliProcessAdapter { + fn handshake<'a>( + &'a self, + request: loopx_contract::LoopxCliHandshakeRequest, + progress: &'a dyn loopx_contract::LoopxCliProgressSink, + ) -> loopx_contract::LoopxCliFuture<'a, loopx_contract::LoopxCliManifest> { + Box::pin(async move { + validate_operation_id(&request.call.operation_id)?; + report_port_progress( + progress, + &request.call.operation_id, + None, + loopx_contract::LoopxCliProgressStage::StartingSidecar, + "Selecting the managed LoopX executable", + ); + if request.required_loopx_version != LOOPX_PINNED_VERSION { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::VersionMismatch, + &request.call.operation_id, + format!( + "adapter is pinned to LoopX {LOOPX_PINNED_VERSION}; requested {}", + request.required_loopx_version + ), + false, + )); + } + if request.required_schema_version != loopx_contract::LOOPX_CLI_SCHEMA_VERSION { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + &request.call.operation_id, + format!( + "adapter schema is {}; requested {}", + loopx_contract::LOOPX_CLI_SCHEMA_VERSION, + request.required_schema_version + ), + false, + )); + } + let deadline = effective_deadline( + request.call.deadline_at, + self.config.startup_deadline, + &request.call.operation_id, + )?; + let observer = PortProcessObserver { + progress, + fallback: self.observer.as_ref(), + task_id: None, + stage: loopx_contract::LoopxCliProgressStage::Handshake, + }; + let (cancellation, _registration) = self + .register_operation(&request.call.operation_id) + .map_err(|error| map_port_error(error, &request.call.operation_id))?; + let verified = self + .ensure_verified( + &request.call.operation_id, + cancellation, + deadline, + &observer, + ) + .await + .map_err(|error| map_port_error(error, &request.call.operation_id))?; + let mut capabilities = vec![ + "issue_fix_workflow_plan_v0".to_string(), + "goal_bootstrap_v0".to_string(), + "loopx_turn_plan_v0".to_string(), + "heartbeat_prompt_v0".to_string(), + "typed_gate_decision_v0".to_string(), + "managed_process_tree_v1".to_string(), + ]; + if self.intake_metadata_configured { + capabilities.push("intake_metadata_provider_v1".to_string()); + } + Ok(loopx_contract::LoopxCliManifest { + adapter_version: env!("CARGO_PKG_VERSION").to_string(), + loopx_version: verified.version, + schema_version: loopx_contract::LOOPX_CLI_SCHEMA_VERSION, + executable: loopx_contract::LoopxCliExecutableIdentity { + source: match verified.source { + LoopxCommandSource::PackagedBundle => { + loopx_contract::LoopxCliSource::Bundled + } + LoopxCommandSource::FixedSystemCommand => { + loopx_contract::LoopxCliSource::System + } + }, + identity: match verified.source { + LoopxCommandSource::PackagedBundle => { + "bitfun-bundled-loopx-v0.2.13".to_string() + } + LoopxCommandSource::FixedSystemCommand => { + "fixed-system-loopx-v0.2.13".to_string() + } + }, + path: Some(verified.executable.to_string_lossy().into_owned()), + sha256: verified.sha256.map(|digest| format!("sha256:{digest}")), + }, + capabilities, + }) + }) + } + + fn resolve_intake<'a>( + &'a self, + request: loopx_contract::LoopxCliResolveIntakeRequest, + progress: &'a dyn loopx_contract::LoopxCliProgressSink, + ) -> loopx_contract::LoopxCliFuture<'a, loopx_contract::LoopxCliResolveIntakeResult> { + Box::pin(async move { + validate_operation_id(&request.call.operation_id)?; + let deadline = effective_deadline( + request.call.deadline_at, + self.config.command_deadline, + &request.call.operation_id, + )?; + report_port_progress( + progress, + &request.call.operation_id, + None, + loopx_contract::LoopxCliProgressStage::ResolvingIntake, + "Resolving live repository metadata", + ); + self.intake_metadata.resolve(&request, deadline).await + }) + } + + fn probe_github_auth<'a>( + &'a self, + request: loopx_contract::LoopxGithubAuthProbeRequest, + ) -> loopx_contract::LoopxCliFuture<'a, loopx_contract::LoopxGithubAuthProbe> { + Box::pin(async move { + validate_operation_id(&request.call.operation_id)?; + let deadline = effective_deadline( + request.call.deadline_at, + self.config.command_deadline, + &request.call.operation_id, + )?; + self.intake_metadata.probe_auth(deadline).await + }) + } + + fn plan_item<'a>( + &'a self, + request: loopx_contract::LoopxCliPlanItemRequest, + progress: &'a dyn loopx_contract::LoopxCliProgressSink, + ) -> loopx_contract::LoopxCliFuture<'a, loopx_contract::LoopxCliIntakePlan> { + Box::pin(async move { + validate_goal_context(&request.context)?; + validate_github_item(&request.item, &request.context.call.operation_id)?; + let observer = PortProcessObserver { + progress, + fallback: self.observer.as_ref(), + task_id: Some(request.context.task_id.clone()), + stage: loopx_contract::LoopxCliProgressStage::PlanningItem, + }; + let operation_id = &request.context.call.operation_id; + report_port_progress( + progress, + operation_id, + Some(request.context.task_id.clone()), + loopx_contract::LoopxCliProgressStage::PlanningItem, + "Building the pinned LoopX issue-fix plan", + ); + let deadline = effective_deadline( + request.context.call.deadline_at, + self.config.command_deadline, + operation_id, + )?; + let output = self + .run_json_command( + operation_id, + Path::new(&request.context.registry_path), + Some(Path::new(&request.context.worktree_path)), + plan_item_args(&request), + deadline, + &observer, + ) + .await + .map_err(|error| map_port_error(error, operation_id))?; + require_payload_ok(&output.payload, operation_id)?; + require_schema( + &output.payload, + "issue_fix_workflow_plan_packet_v0", + operation_id, + )?; + let previews = output + .payload + .get("ordered_loopx_todo_writeback_preview") + .and_then(Value::as_array) + .ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + "workflow plan did not contain ordered_loopx_todo_writeback_preview", + false, + ) + })?; + let mut todos = Vec::with_capacity(previews.len()); + for preview in previews { + let role = required_json_string(preview, "role", operation_id)?; + let task_class = required_json_string(preview, "task_class", operation_id)?; + let text = required_json_string(preview, "text", operation_id)?; + let action_kind = preview + .get("action_kind") + .and_then(Value::as_str) + .map(str::to_string); + todos.push(loopx_contract::LoopxCliTodoPlan { + role, + task_class, + action_kind, + text, + }); + } + if todos.is_empty() { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::Backend, + operation_id, + "workflow plan produced no writable todos", + true, + )); + } + let objective = output + .payload + .get("objective") + .and_then(Value::as_str) + .or_else(|| { + output + .payload + .pointer("/issue_signal/title") + .and_then(Value::as_str) + }) + .map(str::to_string) + .unwrap_or_else(|| format!("Fix {}", request.item.canonical_url())); + Ok(loopx_contract::LoopxCliIntakePlan { + item: request.item, + objective, + todos, + }) + }) + } + + fn create_goal<'a>( + &'a self, + request: loopx_contract::LoopxCliCreateGoalRequest, + progress: &'a dyn loopx_contract::LoopxCliProgressSink, + ) -> loopx_contract::LoopxCliFuture<'a, loopx_contract::LoopxCliCreateGoalResult> { + Box::pin(async move { + validate_goal_context(&request.context)?; + validate_nonempty( + "goal_id", + &request.goal_id, + &request.context.call.operation_id, + )?; + validate_nonempty( + "agent_id", + &request.agent_id, + &request.context.call.operation_id, + )?; + if request.intake.todos.is_empty() { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::InvalidInput, + &request.context.call.operation_id, + "create_goal requires at least one planned todo", + false, + )); + } + let operation_id = &request.context.call.operation_id; + let observer = PortProcessObserver { + progress, + fallback: self.observer.as_ref(), + task_id: Some(request.context.task_id.clone()), + stage: loopx_contract::LoopxCliProgressStage::CreatingGoal, + }; + report_port_progress( + progress, + operation_id, + Some(request.context.task_id.clone()), + loopx_contract::LoopxCliProgressStage::CreatingGoal, + "Creating one LoopX goal for the selected item", + ); + + let bootstrap = + run_port_command(self, &request.context, bootstrap_args(&request), &observer) + .await?; + require_payload_ok(&bootstrap.payload, operation_id)?; + let state_action = bootstrap + .payload + .get("state_action") + .and_then(Value::as_str) + .ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + "bootstrap response did not contain state_action", + false, + ) + })?; + let created = matches!(state_action, "created" | "replaced"); + + let registration = run_port_command( + self, + &request.context, + register_agent_args(&request), + &observer, + ) + .await?; + require_payload_ok(®istration.payload, operation_id)?; + + let existing_todos = run_port_command( + self, + &request.context, + list_todos_args(&request.goal_id), + &observer, + ) + .await?; + require_payload_ok(&existing_todos.payload, operation_id)?; + let existing_todos = existing_todos + .payload + .get("todos") + .and_then(Value::as_array) + .cloned() + .ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + "todo list response did not contain todos", + false, + ) + })?; + for todo in &request.intake.todos { + if existing_todos + .iter() + .any(|existing| todo_matches(existing, todo)) + { + continue; + } + let result = run_port_command( + self, + &request.context, + add_todo_args(&request, todo)?, + &observer, + ) + .await?; + require_payload_ok(&result.payload, operation_id)?; + } + + let inspection = run_port_command( + self, + &request.context, + inspect_goal_args(&request.goal_id, &request.agent_id, None), + &observer, + ) + .await?; + require_payload_ok(&inspection.payload, operation_id)?; + let durable_revision = extract_durable_revision(&inspection.payload, operation_id)?; + Ok(loopx_contract::LoopxCliCreateGoalResult { + goal_id: request.goal_id, + created, + durable_revision, + }) + }) + } + + fn inspect_goal<'a>( + &'a self, + request: loopx_contract::LoopxCliInspectGoalRequest, + progress: &'a dyn loopx_contract::LoopxCliProgressSink, + ) -> loopx_contract::LoopxCliFuture<'a, loopx_contract::LoopxCliGoalSnapshot> { + Box::pin(async move { + validate_goal_context(&request.context)?; + let operation_id = &request.context.call.operation_id; + let observer = PortProcessObserver { + progress, + fallback: self.observer.as_ref(), + task_id: Some(request.context.task_id.clone()), + stage: loopx_contract::LoopxCliProgressStage::InspectingGoal, + }; + report_port_progress( + progress, + operation_id, + Some(request.context.task_id.clone()), + loopx_contract::LoopxCliProgressStage::InspectingGoal, + "Inspecting durable LoopX goal state", + ); + let output = run_port_command( + self, + &request.context, + inspect_goal_args(&request.goal_id, &request.agent_id, None), + &observer, + ) + .await?; + project_goal_snapshot(&request.goal_id, &output.payload, operation_id) + }) + } + + fn build_turn<'a>( + &'a self, + request: loopx_contract::LoopxCliBuildTurnRequest, + progress: &'a dyn loopx_contract::LoopxCliProgressSink, + ) -> loopx_contract::LoopxCliFuture<'a, loopx_contract::LoopxCliBuildTurnResult> { + Box::pin(async move { + validate_goal_context(&request.context)?; + let operation_id = &request.context.call.operation_id; + let observer = PortProcessObserver { + progress, + fallback: self.observer.as_ref(), + task_id: Some(request.context.task_id.clone()), + stage: loopx_contract::LoopxCliProgressStage::BuildingTurn, + }; + report_port_progress( + progress, + operation_id, + Some(request.context.task_id.clone()), + loopx_contract::LoopxCliProgressStage::BuildingTurn, + "Building an idempotent external-host LoopX turn", + ); + let turn_id = stable_turn_id(&request); + let plan = run_port_command( + self, + &request.context, + inspect_goal_args(&request.goal_id, &request.agent_id, Some(&turn_id)), + &observer, + ) + .await?; + require_payload_ok(&plan.payload, operation_id)?; + require_schema(&plan.payload, "loopx_turn_plan_v0", operation_id)?; + let durable_revision = extract_durable_revision(&plan.payload, operation_id)?; + if durable_revision != request.expected_durable_revision { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::Conflict, + operation_id, + "durable LoopX state changed before the turn was built", + true, + )); + } + let settlement_token = plan + .payload + .pointer("/transaction/turn_key") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + "turn plan did not contain transaction.turn_key", + false, + ) + })? + .to_string(); + let prompt_output = run_port_command( + self, + &request.context, + heartbeat_prompt_args(&request.goal_id, &request.agent_id), + &observer, + ) + .await?; + require_payload_ok(&prompt_output.payload, operation_id)?; + let raw_prompt = prompt_output + .payload + .get("task_body") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + "heartbeat-prompt did not produce task_body", + false, + ) + })?; + let verified = self.verified.lock().await.clone().ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::Backend, + operation_id, + "LoopX executable identity disappeared after handshake", + true, + ) + })?; + let command = agent_shell_command(&verified.executable); + let prompt = format!( + "LoopX control-plane binding for this turn:\n- Repository: {}\n- Registry: {}\n- Exact command: {}\n- Turn id: {}\nUse this exact command and registry for every LoopX progress, todo, refresh, and settlement operation. Do not probe PATH and do not use a global registry. The host will accept success only when LoopX exposes a matching durable validation/settlement receipt.\n\n{}", + request.context.worktree_path, + request.context.registry_path, + command, + turn_id, + raw_prompt + .replace("${LOOPX_TURN:?}", &turn_id) + .replace("loopx ", &format!("{command} ")), + ); + Ok(loopx_contract::LoopxCliBuildTurnResult { + goal_id: request.goal_id, + turn_id, + prompt, + settlement_token, + durable_revision, + scheduler_hint_ms: scheduler_hint_ms(&plan.payload), + deadline_at: request.context.call.deadline_at, + }) + }) + } + + fn answer_gate<'a>( + &'a self, + request: loopx_contract::LoopxCliAnswerGateRequest, + progress: &'a dyn loopx_contract::LoopxCliProgressSink, + ) -> loopx_contract::LoopxCliFuture<'a, loopx_contract::LoopxCliAnswerGateResult> { + Box::pin(async move { + validate_goal_context(&request.context)?; + let operation_id = &request.context.call.operation_id; + let observer = PortProcessObserver { + progress, + fallback: self.observer.as_ref(), + task_id: Some(request.context.task_id.clone()), + stage: loopx_contract::LoopxCliProgressStage::AnsweringGate, + }; + report_port_progress( + progress, + operation_id, + Some(request.context.task_id.clone()), + loopx_contract::LoopxCliProgressStage::AnsweringGate, + "Applying the typed LoopX gate decision", + ); + let decision = run_port_command( + self, + &request.context, + answer_gate_args(&request)?, + &observer, + ) + .await?; + require_payload_ok(&decision.payload, operation_id)?; + let inspection = run_port_command( + self, + &request.context, + inspect_goal_args(&request.goal_id, &request.agent_id, None), + &observer, + ) + .await?; + require_payload_ok(&inspection.payload, operation_id)?; + Ok(loopx_contract::LoopxCliAnswerGateResult { + goal_id: request.goal_id, + gate_id: request.gate_id, + applied: true, + durable_revision: extract_durable_revision(&inspection.payload, operation_id)?, + }) + }) + } + + fn settle_turn<'a>( + &'a self, + request: loopx_contract::LoopxCliSettleTurnRequest, + progress: &'a dyn loopx_contract::LoopxCliProgressSink, + ) -> loopx_contract::LoopxCliFuture<'a, loopx_contract::LoopxCliSettleTurnResult> { + Box::pin(async move { + validate_goal_context(&request.context)?; + validate_nonempty( + "agent_id", + &request.agent_id, + &request.context.call.operation_id, + )?; + report_port_progress( + progress, + &request.context.call.operation_id, + Some(request.context.task_id.clone()), + loopx_contract::LoopxCliProgressStage::SettlingTurn, + "Verifying the durable LoopX settlement receipt", + ); + let operation_id = &request.context.call.operation_id; + let observer = PortProcessObserver { + progress, + fallback: self.observer.as_ref(), + task_id: Some(request.context.task_id.clone()), + stage: loopx_contract::LoopxCliProgressStage::SettlingTurn, + }; + let inspection = run_port_command( + self, + &request.context, + inspect_goal_args(&request.goal_id, &request.agent_id, Some(&request.turn_id)), + &observer, + ) + .await?; + require_payload_ok(&inspection.payload, operation_id)?; + require_schema(&inspection.payload, "loopx_turn_plan_v0", operation_id)?; + let snapshot = + project_goal_snapshot(&request.goal_id, &inspection.payload, operation_id)?; + let receipt = + matching_settlement_receipt(&inspection.payload, &request.settlement_token); + let Some(receipt) = receipt else { + let status = if matches!( + request.agent_status, + loopx_contract::LoopxAgentTurnStatus::Failed + | loopx_contract::LoopxAgentTurnStatus::Cancelled + | loopx_contract::LoopxAgentTurnStatus::Interrupted + ) { + loopx_contract::LoopxCliSettlementStatus::RetryRequired + } else { + loopx_contract::LoopxCliSettlementStatus::NoDurableProgress + }; + return Ok(loopx_contract::LoopxCliSettleTurnResult { + goal_id: request.goal_id, + turn_id: request.turn_id, + status, + before_revision: request.expected_durable_revision, + after_revision: snapshot.durable_revision, + scheduler_hint_ms: snapshot.scheduler_hint_ms, + ..loopx_contract::LoopxCliSettleTurnResult::default() + }); + }; + let receipt_id = receipt + .get("receipt_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + "matching LoopX settlement receipt has no receipt_id", + false, + ) + })? + .to_string(); + let validation_succeeded = receipt + .get("validation_succeeded") + .and_then(Value::as_bool) + .or_else(|| { + receipt + .pointer("/validation/succeeded") + .and_then(Value::as_bool) + }) + .unwrap_or(false); + if !validation_succeeded { + return Ok(loopx_contract::LoopxCliSettleTurnResult { + goal_id: request.goal_id, + turn_id: request.turn_id, + receipt_id, + status: loopx_contract::LoopxCliSettlementStatus::NoDurableProgress, + before_revision: request.expected_durable_revision, + after_revision: snapshot.durable_revision, + validation_succeeded: false, + scheduler_hint_ms: snapshot.scheduler_hint_ms, + }); + } + Ok(loopx_contract::LoopxCliSettleTurnResult { + goal_id: request.goal_id, + turn_id: request.turn_id, + receipt_id, + status: if snapshot.state == loopx_contract::LoopxCliGoalState::Completed { + loopx_contract::LoopxCliSettlementStatus::GoalCompleted + } else { + loopx_contract::LoopxCliSettlementStatus::AlreadySettled + }, + before_revision: request.expected_durable_revision, + after_revision: snapshot.durable_revision, + validation_succeeded: true, + scheduler_hint_ms: snapshot.scheduler_hint_ms, + }) + }) + } + + fn cancel<'a>( + &'a self, + request: loopx_contract::LoopxCliCancelRequest, + progress: &'a dyn loopx_contract::LoopxCliProgressSink, + ) -> loopx_contract::LoopxCliFuture<'a, loopx_contract::LoopxCliCancelResult> { + Box::pin(async move { + validate_operation_id(&request.call.operation_id)?; + validate_nonempty( + "target_operation_id", + &request.target_operation_id, + &request.call.operation_id, + )?; + report_port_progress( + progress, + &request.call.operation_id, + None, + loopx_contract::LoopxCliProgressStage::Cancelling, + "Cancelling the managed LoopX process tree", + ); + let cancelled = self.cancel_operation(&request.target_operation_id); + Ok(loopx_contract::LoopxCliCancelResult { + operation_id: request.call.operation_id, + target_operation_id: request.target_operation_id, + cancelled, + }) + }) + } +} + +fn matching_settlement_receipt<'a>(payload: &'a Value, turn_key: &str) -> Option<&'a Value> { + payload + .pointer("/transaction/receipts") + .and_then(Value::as_array)? + .iter() + .find(|receipt| { + receipt.get("turn_key").and_then(Value::as_str) == Some(turn_key) + || receipt.get("settlement_token").and_then(Value::as_str) == Some(turn_key) + }) +} + +fn agent_shell_command(path: &Path) -> String { + let display = path.to_string_lossy().replace('"', "\\\""); + if cfg!(windows) { + format!("& \"{display}\"") + } else { + format!("'{}'", display.replace('\'', "'\\''")) + } +} + +async fn run_port_command( + adapter: &LoopxCliProcessAdapter, + context: &loopx_contract::LoopxCliGoalContext, + args: Vec, + observer: &dyn LoopxProcessObserver, +) -> loopx_contract::LoopxCliResult { + let operation_id = &context.call.operation_id; + let deadline = effective_deadline( + context.call.deadline_at, + adapter.config.command_deadline, + operation_id, + )?; + adapter + .run_json_command( + operation_id, + Path::new(&context.registry_path), + Some(Path::new(&context.worktree_path)), + args, + deadline, + observer, + ) + .await + .map_err(|error| map_port_error(error, operation_id)) +} + +fn plan_item_args(request: &loopx_contract::LoopxCliPlanItemRequest) -> Vec { + [ + "issue-fix".to_string(), + "workflow-plan".to_string(), + "--url".to_string(), + request.item.canonical_url(), + "--repo-path".to_string(), + request.context.worktree_path.clone(), + "--fetch-metadata".to_string(), + "--fetch-timeout-seconds".to_string(), + "20".to_string(), + ] + .into_iter() + .map(OsString::from) + .collect() +} + +fn bootstrap_args(request: &loopx_contract::LoopxCliCreateGoalRequest) -> Vec { + let mut args = vec![ + "bootstrap".to_string(), + "--project".to_string(), + request.context.worktree_path.clone(), + "--goal-id".to_string(), + request.goal_id.clone(), + "--objective".to_string(), + request.intake.objective.clone(), + "--adapter-kind".to_string(), + "read_only_project_map_v0".to_string(), + "--adapter-status".to_string(), + "connected-read-only".to_string(), + "--no-onboarding-scan".to_string(), + "--codex-app-heartbeat".to_string(), + "no".to_string(), + ]; + if request + .granted_scopes + .contains(&loopx_contract::LoopxPermissionScope::WorkspaceWrite) + { + args.extend(["--write-scope".to_string(), "write".to_string()]); + } + args.into_iter().map(OsString::from).collect() +} + +fn register_agent_args(request: &loopx_contract::LoopxCliCreateGoalRequest) -> Vec { + [ + "register-agent", + "--goal-id", + request.goal_id.as_str(), + "--agent-id", + request.agent_id.as_str(), + "--execute", + ] + .into_iter() + .map(OsString::from) + .collect() +} + +fn list_todos_args(goal_id: &str) -> Vec { + ["todo", "list", "--goal-id", goal_id] + .into_iter() + .map(OsString::from) + .collect() +} + +fn todo_matches(existing: &Value, planned: &loopx_contract::LoopxCliTodoPlan) -> bool { + existing.get("role").and_then(Value::as_str) == Some(planned.role.as_str()) + && existing.get("task_class").and_then(Value::as_str) == Some(planned.task_class.as_str()) + && existing.get("text").and_then(Value::as_str) == Some(planned.text.as_str()) + && existing.get("action_kind").and_then(Value::as_str) == planned.action_kind.as_deref() +} + +fn add_todo_args( + request: &loopx_contract::LoopxCliCreateGoalRequest, + todo: &loopx_contract::LoopxCliTodoPlan, +) -> loopx_contract::LoopxCliResult> { + let operation_id = &request.context.call.operation_id; + if !matches!(todo.role.as_str(), "agent" | "user") { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::InvalidInput, + operation_id, + "todo role must be agent or user", + false, + )); + } + if !matches!( + todo.task_class.as_str(), + "advancement_task" | "continuous_monitor" | "user_gate" | "user_action" | "blocker" + ) { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::InvalidInput, + operation_id, + "todo task_class is not supported by LoopX v0.2.13", + false, + )); + } + validate_nonempty("todo.text", &todo.text, operation_id)?; + let repository = &request.intake.item.repository; + let mut args = vec![ + "todo".to_string(), + "add".to_string(), + "--goal-id".to_string(), + request.goal_id.clone(), + "--role".to_string(), + todo.role.clone(), + "--task-class".to_string(), + todo.task_class.clone(), + "--text".to_string(), + todo.text.clone(), + ]; + if let Some(action_kind) = &todo.action_kind { + if !is_public_token(action_kind) { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::InvalidInput, + operation_id, + "todo action_kind must be a bounded public-safe token", + false, + )); + } + args.extend(["--action-kind".to_string(), action_kind.clone()]); + } + if todo.role == "agent" { + args.extend(["--claimed-by".to_string(), request.agent_id.clone()]); + args.extend([ + "--task-repository".to_string(), + format!( + "git:{}/{}/{}", + repository.host, repository.owner, repository.repository + ) + .to_lowercase(), + ]); + } else { + args.extend(["--agent-id".to_string(), request.agent_id.clone()]); + } + Ok(args.into_iter().map(OsString::from).collect()) +} + +fn inspect_goal_args(goal_id: &str, agent_id: &str, turn_id: Option<&str>) -> Vec { + let mut args = vec![ + "turn".to_string(), + "plan".to_string(), + "--goal-id".to_string(), + goal_id.to_string(), + "--agent-id".to_string(), + agent_id.to_string(), + "--host".to_string(), + "generic-cli".to_string(), + "--execution-mode".to_string(), + "isolated-headless".to_string(), + "--scheduler-owner".to_string(), + "outer_controller".to_string(), + "--include-transaction-detail".to_string(), + ]; + if let Some(turn_id) = turn_id { + args.extend(["--turn-instance-id".to_string(), turn_id.to_string()]); + } + args.into_iter().map(OsString::from).collect() +} + +fn heartbeat_prompt_args(goal_id: &str, agent_id: &str) -> Vec { + [ + "heartbeat-prompt", + "--goal-id", + goal_id, + "--agent-id", + agent_id, + "--host-surface", + "generic_cli", + "--scheduler-owner", + "outer_controller", + "--execution-mode", + "isolated_headless", + "--compact", + ] + .into_iter() + .map(OsString::from) + .collect() +} + +fn answer_gate_args( + request: &loopx_contract::LoopxCliAnswerGateRequest, +) -> loopx_contract::LoopxCliResult> { + let operation_id = &request.context.call.operation_id; + validate_nonempty("gate_id", &request.gate_id, operation_id)?; + let decision = match request.decision { + loopx_contract::LoopxCliGateDecision::Approve => "approve", + loopx_contract::LoopxCliGateDecision::Reject => "reject", + }; + let mut args = vec![ + "todo".to_string(), + "complete".to_string(), + "--goal-id".to_string(), + request.goal_id.clone(), + "--todo-id".to_string(), + request.gate_id.clone(), + "--decision-outcome".to_string(), + decision.to_string(), + "--agent-id".to_string(), + request.agent_id.clone(), + ]; + if let Some(note) = &request.note { + if note.len() > 500 { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::InvalidInput, + operation_id, + "gate note exceeds the 500-byte adapter limit", + false, + )); + } + args.extend(["--note".to_string(), note.clone()]); + } + Ok(args.into_iter().map(OsString::from).collect()) +} + +fn project_goal_snapshot( + goal_id: &str, + payload: &Value, + operation_id: &str, +) -> loopx_contract::LoopxCliResult { + require_payload_ok(payload, operation_id)?; + require_schema(payload, "loopx_turn_plan_v0", operation_id)?; + let envelope = payload.get("turn_envelope").ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + "turn plan did not contain turn_envelope", + false, + ) + })?; + let should_run = envelope.get("should_run").and_then(Value::as_bool) == Some(true); + let user_action_required = envelope + .pointer("/user/action_required") + .and_then(Value::as_bool) + == Some(true); + let state_text = envelope + .get("state") + .and_then(Value::as_str) + .unwrap_or_default(); + let effective_action = envelope + .get("effective_action") + .and_then(Value::as_str) + .unwrap_or_default(); + let run_decision = if should_run { + loopx_contract::LoopxCliRunDecision::RunNow + } else if user_action_required || state_text.contains("waiting") { + loopx_contract::LoopxCliRunDecision::WaitingForUser + } else if matches!(state_text, "completed" | "complete" | "closed") { + loopx_contract::LoopxCliRunDecision::Complete + } else if matches!(state_text, "failed" | "error") { + loopx_contract::LoopxCliRunDecision::Failed + } else { + loopx_contract::LoopxCliRunDecision::Wait + }; + let state = match run_decision { + loopx_contract::LoopxCliRunDecision::RunNow => loopx_contract::LoopxCliGoalState::Active, + loopx_contract::LoopxCliRunDecision::WaitingForUser => { + loopx_contract::LoopxCliGoalState::WaitingForUser + } + loopx_contract::LoopxCliRunDecision::Complete => { + loopx_contract::LoopxCliGoalState::Completed + } + loopx_contract::LoopxCliRunDecision::Failed => loopx_contract::LoopxCliGoalState::Failed, + loopx_contract::LoopxCliRunDecision::Wait => { + if effective_action == "archived" { + loopx_contract::LoopxCliGoalState::Archived + } else { + loopx_contract::LoopxCliGoalState::Active + } + } + }; + Ok(loopx_contract::LoopxCliGoalSnapshot { + goal_id: goal_id.to_string(), + state, + durable_revision: extract_durable_revision(payload, operation_id)?, + run_decision, + scheduler_hint_ms: scheduler_hint_ms(payload), + open_todo_count: envelope + .get("open_count") + .and_then(Value::as_u64) + .unwrap_or_default() + .try_into() + .unwrap_or(u32::MAX), + waiting_user_todo_count: envelope + .pointer("/user/open_count") + .and_then(Value::as_u64) + .unwrap_or_default() + .try_into() + .unwrap_or(u32::MAX), + last_turn_id: payload + .get("turn_instance_id") + .and_then(Value::as_str) + .map(str::to_string), + settlement_receipt_ids: payload + .pointer("/transaction/receipts") + .and_then(Value::as_array) + .map(|receipts| { + receipts + .iter() + .filter_map(|receipt| { + receipt + .get("receipt_id") + .and_then(Value::as_str) + .map(str::to_string) + }) + .collect() + }) + .unwrap_or_default(), + }) +} + +fn extract_durable_revision( + payload: &Value, + operation_id: &str, +) -> loopx_contract::LoopxCliResult { + [ + "/turn_envelope/action_signature/source_decision_hash", + "/turn_envelope/action_signature/source_hash", + "/transaction/turn_key", + ] + .into_iter() + .find_map(|pointer| { + payload + .pointer(pointer) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + }) + .map(str::to_string) + .ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + "LoopX turn plan did not expose a durable revision identity", + false, + ) + }) +} + +fn stable_turn_id(request: &loopx_contract::LoopxCliBuildTurnRequest) -> String { + let mut hasher = Sha256::new(); + hasher.update(request.context.task_id.as_bytes()); + hasher.update([0]); + hasher.update(request.context.generation.to_le_bytes()); + hasher.update([0]); + hasher.update(request.goal_id.as_bytes()); + hasher.update([0]); + hasher.update(request.expected_durable_revision.as_bytes()); + let digest = hex::encode(hasher.finalize()); + format!("bitfun-{}", &digest[..32]) +} + +fn scheduler_hint_ms(payload: &Value) -> Option { + [ + "/turn_envelope/scheduler/hint_ms", + "/turn_envelope/scheduler/next_poll_ms", + "/scheduler_hint_ms", + ] + .into_iter() + .find_map(|pointer| payload.pointer(pointer).and_then(Value::as_u64)) +} + +fn require_payload_ok(payload: &Value, operation_id: &str) -> loopx_contract::LoopxCliResult<()> { + if payload.get("ok").and_then(Value::as_bool) == Some(false) { + let message = payload + .get("error") + .and_then(Value::as_str) + .unwrap_or("LoopX rejected the operation"); + return Err(port_error( + loopx_contract::LoopxCliErrorKind::Backend, + operation_id, + truncate_message(message), + false, + )); + } + Ok(()) +} + +fn require_schema( + payload: &Value, + expected: &str, + operation_id: &str, +) -> loopx_contract::LoopxCliResult<()> { + let actual = payload + .get("schema_version") + .and_then(Value::as_str) + .unwrap_or_default(); + if actual != expected { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + format!("expected response schema {expected}, got {actual}"), + false, + )); + } + Ok(()) +} + +fn required_json_string( + payload: &Value, + field: &str, + operation_id: &str, +) -> loopx_contract::LoopxCliResult { + payload + .get(field) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + format!("LoopX response field {field} is missing or invalid"), + false, + ) + }) +} + +fn validate_goal_context( + context: &loopx_contract::LoopxCliGoalContext, +) -> loopx_contract::LoopxCliResult<()> { + let operation_id = &context.call.operation_id; + validate_operation_id(operation_id)?; + validate_nonempty("task_id", &context.task_id, operation_id)?; + validate_nonempty("worktree_path", &context.worktree_path, operation_id)?; + validate_nonempty("registry_path", &context.registry_path, operation_id)?; + if !Path::new(&context.worktree_path).is_absolute() + || !Path::new(&context.registry_path).is_absolute() + { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::InvalidInput, + operation_id, + "LoopX local worktree and registry paths must be absolute", + false, + )); + } + Ok(()) +} + +fn validate_github_item( + item: &loopx_contract::LoopxIssueKey, + operation_id: &str, +) -> loopx_contract::LoopxCliResult<()> { + if !item.repository.host.eq_ignore_ascii_case("github.com") + || item.repository.owner.is_empty() + || item.repository.repository.is_empty() + || item.number == 0 + { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::InvalidInput, + operation_id, + "LoopX v0.2.13 issue-fix planning requires a canonical GitHub item", + false, + )); + } + Ok(()) +} + +fn validate_operation_id(operation_id: &str) -> loopx_contract::LoopxCliResult<()> { + validate_nonempty("operation_id", operation_id, operation_id) +} + +fn validate_nonempty( + field: &str, + value: &str, + operation_id: &str, +) -> loopx_contract::LoopxCliResult<()> { + if value.trim().is_empty() { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::InvalidInput, + operation_id, + format!("{field} is required"), + false, + )); + } + Ok(()) +} + +fn effective_deadline( + deadline_at: Option, + configured: Duration, + operation_id: &str, +) -> loopx_contract::LoopxCliResult { + let Some(deadline_at) = deadline_at else { + return Ok(configured); + }; + let remaining_ms = deadline_at.saturating_sub(now_unix_ms()); + if remaining_ms <= 0 { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::Timeout, + operation_id, + "LoopX operation deadline has already expired", + true, + )); + } + Ok(configured.min(Duration::from_millis( + remaining_ms.try_into().unwrap_or(u64::MAX), + ))) +} + +fn map_port_error( + error: LoopxCliAdapterError, + operation_id: &str, +) -> loopx_contract::LoopxCliError { + let (kind, retryable) = match &error { + LoopxCliAdapterError::Unavailable => (loopx_contract::LoopxCliErrorKind::NotFound, true), + LoopxCliAdapterError::Manifest { .. } => (loopx_contract::LoopxCliErrorKind::Io, false), + LoopxCliAdapterError::VersionMismatch { .. } => { + (loopx_contract::LoopxCliErrorKind::VersionMismatch, false) + } + LoopxCliAdapterError::SchemaMismatch { .. } => { + (loopx_contract::LoopxCliErrorKind::SchemaMismatch, false) + } + LoopxCliAdapterError::Conflict { .. } => { + (loopx_contract::LoopxCliErrorKind::Conflict, true) + } + LoopxCliAdapterError::InvalidJson { .. } => { + (loopx_contract::LoopxCliErrorKind::Backend, false) + } + LoopxCliAdapterError::Process(LoopxProcessError::Timeout { .. }) => { + (loopx_contract::LoopxCliErrorKind::Timeout, true) + } + LoopxCliAdapterError::Process(LoopxProcessError::Cancelled { .. }) => { + (loopx_contract::LoopxCliErrorKind::Cancelled, true) + } + LoopxCliAdapterError::Process(LoopxProcessError::Io { .. }) => { + (loopx_contract::LoopxCliErrorKind::Io, true) + } + LoopxCliAdapterError::Process(_) => (loopx_contract::LoopxCliErrorKind::Process, true), + }; + port_error(kind, operation_id, error.to_string(), retryable) +} + +fn port_error( + kind: loopx_contract::LoopxCliErrorKind, + operation_id: &str, + message: impl Into, + retryable: bool, +) -> loopx_contract::LoopxCliError { + loopx_contract::LoopxCliError::new(kind, message) + .for_operation(operation_id) + .retryable(retryable) +} + +fn report_port_progress( + progress: &dyn loopx_contract::LoopxCliProgressSink, + operation_id: &str, + task_id: Option, + stage: loopx_contract::LoopxCliProgressStage, + message: &str, +) { + progress.report(loopx_contract::LoopxCliProgress { + operation_id: operation_id.to_string(), + task_id, + stage, + message: message.to_string(), + occurred_at: now_unix_ms(), + }); +} + +fn now_unix_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(i64::MAX) +} + +fn truncate_message(message: &str) -> String { + message.chars().take(500).collect() +} + +fn is_public_token(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) +} + +#[derive(Debug)] +struct LoopxCandidate { + executable: PathBuf, + source: LoopxCommandSource, + bundle_manifest_schema: Option, + sha256: Option, +} + +#[derive(Debug, Deserialize)] +struct BundledLoopxManifest { + schema_version: u32, + name: String, + version: String, + sha256: String, +} + +fn verify_manifest(manifest: &BundledLoopxManifest) -> Result<(), LoopxCliAdapterError> { + if manifest.schema_version != LOOPX_BUNDLE_MANIFEST_SCHEMA { + return Err(LoopxCliAdapterError::SchemaMismatch { + expected: LOOPX_BUNDLE_MANIFEST_SCHEMA.to_string(), + actual: manifest.schema_version.to_string(), + }); + } + if manifest.name != "loopx" { + return Err(LoopxCliAdapterError::Manifest { + message: format!("expected bundle name loopx, got {}", manifest.name), + }); + } + if manifest.version != LOOPX_PINNED_VERSION_TAG { + return Err(LoopxCliAdapterError::VersionMismatch { + expected: LOOPX_PINNED_VERSION_TAG.to_string(), + actual: manifest.version.clone(), + }); + } + let digest = manifest.sha256.strip_prefix("sha256:").unwrap_or_default(); + if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(LoopxCliAdapterError::Manifest { + message: "manifest sha256 must contain a 64-digit sha256 digest".to_string(), + }); + } + Ok(()) +} + +async fn sha256_file(path: &Path) -> Result { + let mut file = + tokio::fs::File::open(path) + .await + .map_err(|error| LoopxCliAdapterError::Manifest { + message: error.to_string(), + })?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = + file.read(&mut buffer) + .await + .map_err(|error| LoopxCliAdapterError::Manifest { + message: error.to_string(), + })?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(hex::encode(hasher.finalize())) +} + +struct OperationRegistration { + operation_id: String, + running: Arc>>, +} + +impl Drop for OperationRegistration { + fn drop(&mut self) { + self.running + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .remove(&self.operation_id); + } +} + +struct StdoutCapture { + bytes: Vec, + exceeded_limit: bool, +} + +async fn capture_stdout(mut reader: impl AsyncRead + Unpin) -> StdoutCapture { + let mut bytes = Vec::new(); + let mut exceeded_limit = false; + let mut buffer = [0_u8; 16 * 1024]; + loop { + let read = match reader.read(&mut buffer).await { + Ok(0) | Err(_) => break, + Ok(read) => read, + }; + let remaining = MAX_STDOUT_BYTES.saturating_sub(bytes.len()); + if read > remaining { + bytes.extend_from_slice(&buffer[..remaining]); + exceeded_limit = true; + } else { + bytes.extend_from_slice(&buffer[..read]); + } + } + StdoutCapture { + bytes, + exceeded_limit, + } +} + +async fn capture_stderr( + mut reader: impl AsyncRead + Unpin, + line_sender: mpsc::Sender, +) -> Vec { + let mut pending = Vec::new(); + let mut tail = VecDeque::new(); + let mut tail_bytes = 0_usize; + let mut buffer = [0_u8; 8 * 1024]; + loop { + let read = match reader.read(&mut buffer).await { + Ok(0) | Err(_) => break, + Ok(read) => read, + }; + pending.extend_from_slice(&buffer[..read]); + while let Some(newline) = pending.iter().position(|byte| *byte == b'\n') { + let line = pending.drain(..=newline).collect::>(); + record_stderr_line(&line, &line_sender, &mut tail, &mut tail_bytes); + } + if pending.len() > MAX_PROGRESS_LINE_BYTES * 2 { + let line = pending.drain(..MAX_PROGRESS_LINE_BYTES).collect::>(); + record_stderr_line(&line, &line_sender, &mut tail, &mut tail_bytes); + } + } + if !pending.is_empty() { + record_stderr_line(&pending, &line_sender, &mut tail, &mut tail_bytes); + } + tail.into_iter().collect() +} + +fn record_stderr_line( + raw: &[u8], + line_sender: &mpsc::Sender, + tail: &mut VecDeque, + tail_bytes: &mut usize, +) { + let raw = raw + .strip_suffix(b"\n") + .unwrap_or(raw) + .strip_suffix(b"\r") + .unwrap_or(raw); + if raw.is_empty() { + return; + } + let line = String::from_utf8_lossy(&raw[..raw.len().min(MAX_PROGRESS_LINE_BYTES)]).into_owned(); + let _ = line_sender.try_send(line.clone()); + *tail_bytes += line.len(); + tail.push_back(line); + while *tail_bytes > MAX_STDERR_TAIL_BYTES { + if let Some(removed) = tail.pop_front() { + *tail_bytes = tail_bytes.saturating_sub(removed.len()); + } else { + break; + } + } +} + +async fn drain_stdout_task( + task: &mut tokio::task::JoinHandle, +) -> Result { + match tokio::time::timeout(PIPE_DRAIN_DEADLINE, &mut *task).await { + Ok(Ok(capture)) => Ok(capture), + Ok(Err(error)) => Err(LoopxProcessError::Io { + message: error.to_string(), + }), + Err(_) => { + task.abort(); + Err(LoopxProcessError::Io { + message: "LoopX stdout pipe stayed open after process exit".to_string(), + }) + } + } +} + +async fn drain_stderr_task(task: &mut tokio::task::JoinHandle>) -> Vec { + match tokio::time::timeout(PIPE_DRAIN_DEADLINE, &mut *task).await { + Ok(Ok(tail)) => tail, + Ok(Err(_)) => Vec::new(), + Err(_) => { + task.abort(); + Vec::new() + } + } +} + +fn emit_progress( + observer: &dyn LoopxProcessObserver, + operation_id: &str, + stage: LoopxProgressStage, + message: &str, +) { + observer.on_progress(LoopxProcessProgress { + operation_id: operation_id.to_string(), + stage, + message: message.to_string(), + occurred_at_unix_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX), + }); +} + +fn duration_millis(duration: Duration) -> u64 { + duration.as_millis().try_into().unwrap_or(u64::MAX) +} diff --git a/src/crates/services/services-integrations/src/miniapp/loopx_github.rs b/src/crates/services/services-integrations/src/miniapp/loopx_github.rs new file mode 100644 index 0000000000..ca4bbc809a --- /dev/null +++ b/src/crates/services/services-integrations/src/miniapp/loopx_github.rs @@ -0,0 +1,307 @@ +use super::loopx_cli::LoopxIntakeMetadataProvider; +use async_trait::async_trait; +use bitfun_product_domains::miniapp::loopx as loopx_contract; +use reqwest::{Client, StatusCode}; +use serde_json::Value; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const GITHUB_API_ROOT: &str = "https://api.github.com"; +const INTAKE_PAGE_SIZE: usize = 50; + +#[derive(Clone)] +pub struct GithubLoopxIntakeMetadataProvider { + client: Client, +} + +impl GithubLoopxIntakeMetadataProvider { + pub fn new() -> Result { + let client = Client::builder() + .user_agent("BitFun LoopX MiniApp") + .build() + .map_err(|error| format!("Failed to build GitHub client: {error}"))?; + Ok(Self { client }) + } + + async fn get_json( + &self, + path: &str, + deadline: Duration, + operation_id: &str, + ) -> loopx_contract::LoopxCliResult { + let response = self + .client + .get(format!("{GITHUB_API_ROOT}{path}")) + .timeout(deadline) + .send() + .await + .map_err(|error| { + loopx_contract::LoopxCliError::new( + if error.is_timeout() { + loopx_contract::LoopxCliErrorKind::Timeout + } else { + loopx_contract::LoopxCliErrorKind::Backend + }, + format!("GitHub intake request failed: {error}"), + ) + .for_operation(operation_id) + .retryable(true) + })?; + let status = response.status(); + if status == StatusCode::NOT_FOUND { + return Err(loopx_contract::LoopxCliError::new( + loopx_contract::LoopxCliErrorKind::NotFound, + "GitHub repository, issue, or pull request was not found", + ) + .for_operation(operation_id)); + } + if !status.is_success() { + let message = if status == StatusCode::FORBIDDEN { + if response + .headers() + .get("x-ratelimit-remaining") + .and_then(|value| value.to_str().ok()) + == Some("0") + { + "GitHub API rate limit exceeded; authenticate with GitHub to raise the limit" + .to_string() + } else { + "GitHub intake request was forbidden (403); the repository may be private or access is blocked" + .to_string() + } + } else { + format!("GitHub intake request returned HTTP {status}") + }; + return Err(loopx_contract::LoopxCliError::new( + loopx_contract::LoopxCliErrorKind::Backend, + message, + ) + .for_operation(operation_id) + .retryable(status == StatusCode::FORBIDDEN || status.is_server_error())); + } + response.json::().await.map_err(|error| { + loopx_contract::LoopxCliError::new( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + format!("GitHub intake response was invalid: {error}"), + ) + .for_operation(operation_id) + }) + } +} + +#[async_trait] +impl LoopxIntakeMetadataProvider for GithubLoopxIntakeMetadataProvider { + async fn resolve( + &self, + request: &loopx_contract::LoopxCliResolveIntakeRequest, + deadline: Duration, + ) -> loopx_contract::LoopxCliResult { + let repository = request.target.repository().clone(); + let operation_id = &request.call.operation_id; + let mut candidates = Vec::new(); + let mut truncated = false; + match &request.target { + loopx_contract::LoopxIntakeTarget::Item { item } => { + let collection = match item.kind { + loopx_contract::LoopxItemKind::Issue => "issues", + loopx_contract::LoopxItemKind::PullRequest => "pulls", + }; + let value = self + .get_json( + &format!( + "/repos/{}/{}/{collection}/{}", + repository.owner, repository.repository, item.number + ), + deadline, + operation_id, + ) + .await?; + candidates.push(candidate_from_value(item.clone(), &value, false)); + } + loopx_contract::LoopxIntakeTarget::Repository { .. } => { + let value = self + .get_json( + &format!( + "/repos/{}/{}/issues?state=open&sort=updated&direction=desc&per_page={INTAKE_PAGE_SIZE}", + repository.owner, repository.repository + ), + deadline, + operation_id, + ) + .await?; + let rows = value.as_array().ok_or_else(|| { + loopx_contract::LoopxCliError::new( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + "GitHub issues response was not an array", + ) + .for_operation(operation_id) + })?; + truncated = rows.len() == INTAKE_PAGE_SIZE; + for value in rows { + if value.get("pull_request").is_some() { + continue; + } + let Some(number) = value.get("number").and_then(Value::as_u64) else { + continue; + }; + candidates.push(candidate_from_value( + loopx_contract::LoopxIssueKey { + repository: repository.clone(), + kind: loopx_contract::LoopxItemKind::Issue, + number, + }, + value, + true, + )); + } + } + } + Ok(loopx_contract::LoopxCliResolveIntakeResult { + target: request.target.clone(), + repository, + candidates, + truncated, + resolved_at: now_ms(), + }) + } + + async fn probe_auth( + &self, + deadline: Duration, + ) -> loopx_contract::LoopxCliResult { + const OPERATION_ID: &str = "github-auth-probe"; + let response = self + .client + .get(format!("{GITHUB_API_ROOT}/rate_limit")) + .timeout(deadline) + .send() + .await + .map_err(|error| { + loopx_contract::LoopxCliError::new( + if error.is_timeout() { + loopx_contract::LoopxCliErrorKind::Timeout + } else { + loopx_contract::LoopxCliErrorKind::Backend + }, + format!("GitHub auth probe failed: {error}"), + ) + .for_operation(OPERATION_ID) + .retryable(true) + })?; + let status = response.status(); + if !status.is_success() { + let detail = match status { + StatusCode::FORBIDDEN => { + "GitHub API returned 403; the request may be rate-limited or blocked" + } + StatusCode::UNAUTHORIZED => "GitHub API requires authentication for this request", + _ => "GitHub API is unreachable for LoopX intake", + }; + return Ok(loopx_contract::LoopxGithubAuthProbe { + authenticated: false, + detail: Some(detail.to_string()), + ..loopx_contract::LoopxGithubAuthProbe::default() + }); + } + + let parsed = response.json::().await.ok(); + let limit = parsed + .as_ref() + .and_then(|value| value.pointer("/rate/limit")) + .and_then(Value::as_u64); + let remaining = parsed + .as_ref() + .and_then(|value| value.pointer("/rate/remaining")) + .and_then(Value::as_u64); + let authenticated = limit.is_some_and(|value| value >= 1000); + let detail = if remaining == Some(0) { + Some( + "GitHub API rate limit is exhausted; authenticate with GitHub to raise it" + .to_string(), + ) + } else if authenticated { + Some("Authenticated GitHub access".to_string()) + } else { + Some("Unauthenticated GitHub access (60 requests/hour)".to_string()) + }; + Ok(loopx_contract::LoopxGithubAuthProbe { + authenticated, + rate_limit_remaining: remaining, + detail, + }) + } +} + +fn candidate_from_value( + key: loopx_contract::LoopxIssueKey, + value: &Value, + from_repository: bool, +) -> loopx_contract::LoopxIntakeCandidate { + let merged = value.get("merged_at").is_some_and(|value| !value.is_null()); + let state = if merged { + loopx_contract::LoopxRemoteItemState::Merged + } else { + match value.get("state").and_then(Value::as_str) { + Some("open") => loopx_contract::LoopxRemoteItemState::Open, + Some("closed") => loopx_contract::LoopxRemoteItemState::Closed, + _ => loopx_contract::LoopxRemoteItemState::Unknown, + } + }; + let body = value + .get("body") + .and_then(Value::as_str) + .unwrap_or_default(); + loopx_contract::LoopxIntakeCandidate { + url: key.canonical_url(), + title: value + .get("title") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + state, + state_reason: value + .get("state_reason") + .and_then(Value::as_str) + .map(str::to_string), + from_repository, + has_images: body.contains("![") || body.to_ascii_lowercase().contains(" i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn metadata_projection_does_not_retain_issue_body() { + let key = loopx_contract::LoopxIssueKey { + repository: loopx_contract::LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "owner".to_string(), + repository: "repo".to_string(), + }, + kind: loopx_contract::LoopxItemKind::Issue, + number: 7, + }; + let value = serde_json::json!({ + "number": 7, + "title": "Visible title", + "state": "open", + "body": "private-looking body ![image](https://example.test/image.png)" + }); + let candidate = candidate_from_value(key, &value, false); + assert_eq!(candidate.title, "Visible title"); + assert!(candidate.has_images); + assert!(!serde_json::to_string(&candidate) + .expect("serialize") + .contains("private-looking")); + } +} diff --git a/src/crates/services/services-integrations/src/miniapp/loopx_workspace.rs b/src/crates/services/services-integrations/src/miniapp/loopx_workspace.rs new file mode 100644 index 0000000000..7bf91312c3 --- /dev/null +++ b/src/crates/services/services-integrations/src/miniapp/loopx_workspace.rs @@ -0,0 +1,744 @@ +//! Local isolated workspace preparation for LoopX tasks. + +use super::loopx_cli::{ + LoopxCommandPlan, LoopxProcessError, LoopxProcessObserver, LoopxProcessRunner, + NoopLoopxProcessObserver, SystemLoopxProcessRunner, +}; +use bitfun_product_domains::miniapp::loopx as loopx_contract; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, HashMap}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex as StdMutex}; +use std::time::Duration; +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; +use url::Url; + +const WORKSPACE_MARKER_SCHEMA: u32 = 1; +const WORKSPACE_MARKER_NAME: &str = "bitfun-loopx-workspace.json"; + +#[derive(Debug, Clone)] +pub struct LoopxWorkspaceServiceConfig { + pub root_dir: PathBuf, + pub git_executable: PathBuf, + pub clone_deadline: Duration, + pub command_deadline: Duration, + pub terminate_grace: Duration, +} + +impl LoopxWorkspaceServiceConfig { + pub fn new(root_dir: impl Into, git_executable: impl Into) -> Self { + Self { + root_dir: root_dir.into(), + git_executable: git_executable.into(), + clone_deadline: Duration::from_secs(300), + command_deadline: Duration::from_secs(30), + terminate_grace: Duration::from_secs(2), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoopxWorkspaceLayout { + pub repository_identity: String, + pub repository_hash: String, + pub task_hash: String, + pub worktree_path: PathBuf, + pub registry_path: PathBuf, + pub branch_name: String, + pub clone_url: String, +} + +pub fn plan_workspace_layout( + root_dir: &Path, + task_id: &str, + item: &loopx_contract::LoopxIssueKey, +) -> loopx_contract::LoopxHostResult { + validate_task_and_item("workspace-plan", task_id, item)?; + if !root_dir.is_absolute() { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::InvalidInput, + "workspace-plan", + "LoopX workspace root must be absolute", + false, + )); + } + let repository_identity = item.repository.canonical_id().to_lowercase(); + let repository_hash = sha256_prefix(repository_identity.as_bytes(), 20); + let task_hash = sha256_prefix(task_id.as_bytes(), 20); + let worktree_path = root_dir.join(&repository_hash).join(&task_hash); + Ok(LoopxWorkspaceLayout { + repository_identity, + repository_hash, + task_hash: task_hash.clone(), + registry_path: worktree_path.join(".loopx").join("registry.json"), + worktree_path, + branch_name: format!("bitfun-loopx/{task_hash}"), + clone_url: format!( + "https://github.com/{}/{}.git", + item.repository.owner, item.repository.repository + ), + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoopxGitCommandPlan { + pub executable: PathBuf, + pub args: Vec, + pub environment: BTreeMap, + pub current_dir: Option, +} + +pub fn plan_git_clone_command( + git_executable: &Path, + layout: &LoopxWorkspaceLayout, +) -> LoopxGitCommandPlan { + LoopxGitCommandPlan { + executable: git_executable.to_path_buf(), + args: vec![ + OsString::from("clone"), + OsString::from("--no-checkout"), + OsString::from("--origin"), + OsString::from("origin"), + OsString::from("--"), + OsString::from(&layout.clone_url), + layout.worktree_path.as_os_str().to_owned(), + ], + environment: git_noninteractive_environment(), + current_dir: None, + } +} + +pub fn canonical_github_remote(remote: &str) -> Option { + let trimmed = remote.trim().trim_end_matches('/'); + if let Some(path) = trimmed.strip_prefix("git@github.com:") { + return canonical_github_path(path); + } + let parsed = Url::parse(trimmed).ok()?; + if !parsed.host_str()?.eq_ignore_ascii_case("github.com") { + return None; + } + canonical_github_path(parsed.path().trim_start_matches('/')) +} + +pub struct LoopxWorkspaceService { + config: LoopxWorkspaceServiceConfig, + runner: Arc, + observer: Arc, + mutation_lock: Mutex<()>, + running: Arc>>, +} + +impl std::fmt::Debug for LoopxWorkspaceService { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("LoopxWorkspaceService") + .field("config", &self.config) + .finish_non_exhaustive() + } +} + +impl LoopxWorkspaceService { + pub fn new(config: LoopxWorkspaceServiceConfig) -> Self { + Self::with_runner( + config, + Arc::new(SystemLoopxProcessRunner), + Arc::new(NoopLoopxProcessObserver), + ) + } + + pub fn with_runner( + config: LoopxWorkspaceServiceConfig, + runner: Arc, + observer: Arc, + ) -> Self { + Self { + config, + runner, + observer, + mutation_lock: Mutex::new(()), + running: Arc::new(StdMutex::new(HashMap::new())), + } + } + + async fn prepare_inner( + &self, + request: &loopx_contract::LoopxWorkspacePrepareRequest, + cancellation: CancellationToken, + ) -> loopx_contract::LoopxHostResult { + validate_task_and_item(&request.operation_id, &request.task_id, &request.item)?; + tokio::fs::create_dir_all(&self.config.root_dir) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + format!("failed to create LoopX workspace root: {error}"), + true, + ) + })?; + let canonical_root = tokio::fs::canonicalize(&self.config.root_dir) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + format!("failed to resolve LoopX workspace root: {error}"), + true, + ) + })?; + let layout = plan_workspace_layout(&canonical_root, &request.task_id, &request.item)?; + let repository_parent = layout + .worktree_path + .parent() + .expect("hashed workspace layout always has a parent"); + tokio::fs::create_dir_all(repository_parent) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + format!("failed to create repository workspace directory: {error}"), + true, + ) + })?; + ensure_path_boundary(&canonical_root, repository_parent, &request.operation_id).await?; + + if tokio::fs::try_exists(&layout.worktree_path) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + error.to_string(), + true, + ) + })? + { + ensure_path_boundary( + &canonical_root, + &layout.worktree_path, + &request.operation_id, + ) + .await?; + let marker = read_workspace_marker(&layout, &request.operation_id).await?; + validate_marker(&marker, &layout, &request.operation_id)?; + self.verify_remote(&layout, &request.operation_id, cancellation) + .await?; + return Ok(workspace_result(&layout, true)); + } + + let clone = plan_git_clone_command(&self.config.git_executable, &layout); + self.run_git( + &request.operation_id, + clone, + self.config.clone_deadline, + cancellation.clone(), + ) + .await?; + self.verify_remote(&layout, &request.operation_id, cancellation.clone()) + .await?; + self.run_git( + &request.operation_id, + git_checkout_plan(&self.config.git_executable, &layout), + self.config.command_deadline, + cancellation, + ) + .await?; + write_workspace_marker(&layout, &request.task_id, &request.operation_id).await?; + Ok(workspace_result(&layout, false)) + } + + async fn verify_inner( + &self, + request: &loopx_contract::LoopxWorkspaceVerifyRequest, + cancellation: CancellationToken, + ) -> loopx_contract::LoopxHostResult { + validate_task_and_item(&request.operation_id, &request.task_id, &request.item)?; + let canonical_root = match tokio::fs::canonicalize(&self.config.root_dir).await { + Ok(path) => path, + Err(error) => { + return Ok(invalid_workspace(format!( + "workspace root is unavailable: {error}" + ))) + } + }; + let layout = plan_workspace_layout(&canonical_root, &request.task_id, &request.item)?; + if Path::new(&request.worktree_path) != layout.worktree_path + || Path::new(&request.registry_path) != layout.registry_path + { + return Ok(invalid_workspace( + "workspace paths do not match the canonical task layout", + )); + } + if ensure_path_boundary( + &canonical_root, + &layout.worktree_path, + &request.operation_id, + ) + .await + .is_err() + { + return Ok(invalid_workspace( + "workspace resolves outside the managed root", + )); + } + let marker = match read_workspace_marker(&layout, &request.operation_id).await { + Ok(marker) => marker, + Err(error) => return Ok(invalid_workspace(error.message)), + }; + if let Err(error) = validate_marker(&marker, &layout, &request.operation_id) { + return Ok(invalid_workspace(error.message)); + } + if let Err(error) = self + .verify_remote(&layout, &request.operation_id, cancellation) + .await + { + if matches!( + error.kind, + loopx_contract::LoopxHostPortErrorKind::Cancelled + | loopx_contract::LoopxHostPortErrorKind::Timeout + ) { + return Err(error); + } + return Ok(invalid_workspace(error.message)); + } + Ok(loopx_contract::LoopxWorkspaceVerifyResult { + valid: true, + repository: Some(request.item.repository.clone()), + message: None, + }) + } + + async fn verify_remote( + &self, + layout: &LoopxWorkspaceLayout, + operation_id: &str, + cancellation: CancellationToken, + ) -> loopx_contract::LoopxHostResult<()> { + let output = self + .run_git( + operation_id, + git_remote_plan(&self.config.git_executable, layout), + self.config.command_deadline, + cancellation, + ) + .await?; + let actual = canonical_github_remote(output.stdout.trim()).ok_or_else(|| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Conflict, + operation_id, + "workspace origin is not a canonical GitHub repository", + false, + ) + })?; + if actual != layout.repository_identity { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::Conflict, + operation_id, + format!( + "workspace origin mismatch: expected {}, got {actual}; existing data was preserved", + layout.repository_identity + ), + false, + )); + } + Ok(()) + } + + async fn run_git( + &self, + operation_id: &str, + plan: LoopxGitCommandPlan, + deadline: Duration, + cancellation: CancellationToken, + ) -> loopx_contract::LoopxHostResult { + self.runner + .run( + LoopxCommandPlan { + operation_id: operation_id.to_string(), + executable: plan.executable, + args: plan.args, + current_dir: plan.current_dir, + environment: plan.environment, + deadline, + terminate_grace: self.config.terminate_grace, + }, + cancellation, + self.observer.as_ref(), + ) + .await + .map_err(|error| map_process_error(error, operation_id)) + } + + fn register_operation( + &self, + operation_id: &str, + ) -> loopx_contract::LoopxHostResult<(CancellationToken, WorkspaceOperationRegistration)> { + if operation_id.trim().is_empty() { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::InvalidInput, + operation_id, + "operation_id is required", + false, + )); + } + let mut running = self + .running + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + if running.contains_key(operation_id) { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::Conflict, + operation_id, + "workspace operation is already running", + true, + )); + } + let cancellation = CancellationToken::new(); + running.insert(operation_id.to_string(), cancellation.clone()); + Ok(( + cancellation, + WorkspaceOperationRegistration { + operation_id: operation_id.to_string(), + running: self.running.clone(), + }, + )) + } +} + +impl loopx_contract::LoopxWorkspacePort for LoopxWorkspaceService { + fn prepare( + &self, + request: loopx_contract::LoopxWorkspacePrepareRequest, + ) -> loopx_contract::LoopxHostFuture<'_, loopx_contract::LoopxWorkspacePrepareResult> { + Box::pin(async move { + let (cancellation, _registration) = self.register_operation(&request.operation_id)?; + let _mutation = self.mutation_lock.lock().await; + self.prepare_inner(&request, cancellation).await + }) + } + + fn verify( + &self, + request: loopx_contract::LoopxWorkspaceVerifyRequest, + ) -> loopx_contract::LoopxHostFuture<'_, loopx_contract::LoopxWorkspaceVerifyResult> { + Box::pin(async move { + let (cancellation, _registration) = self.register_operation(&request.operation_id)?; + self.verify_inner(&request, cancellation).await + }) + } + + fn cancel( + &self, + request: loopx_contract::LoopxWorkspaceCancelRequest, + ) -> loopx_contract::LoopxHostFuture<'_, loopx_contract::LoopxWorkspaceCancelResult> { + Box::pin(async move { + let running = self + .running + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + let cancelled = if let Some(cancellation) = running.get(&request.target_operation_id) { + cancellation.cancel(); + true + } else { + false + }; + Ok(loopx_contract::LoopxWorkspaceCancelResult { + target_operation_id: request.target_operation_id, + cancelled, + }) + }) + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct LoopxWorkspaceMarker { + schema_version: u32, + repository_identity: String, + repository_hash: String, + task_id_hash: String, + branch_name: String, +} + +struct WorkspaceOperationRegistration { + operation_id: String, + running: Arc>>, +} + +impl Drop for WorkspaceOperationRegistration { + fn drop(&mut self) { + self.running + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .remove(&self.operation_id); + } +} + +fn git_noninteractive_environment() -> BTreeMap { + BTreeMap::from([ + (OsString::from("GIT_TERMINAL_PROMPT"), OsString::from("0")), + (OsString::from("GCM_INTERACTIVE"), OsString::from("Never")), + ]) +} + +fn git_remote_plan(git: &Path, layout: &LoopxWorkspaceLayout) -> LoopxGitCommandPlan { + LoopxGitCommandPlan { + executable: git.to_path_buf(), + args: vec![ + OsString::from("-C"), + layout.worktree_path.as_os_str().to_owned(), + OsString::from("config"), + OsString::from("--get"), + OsString::from("remote.origin.url"), + ], + environment: git_noninteractive_environment(), + current_dir: None, + } +} + +fn git_checkout_plan(git: &Path, layout: &LoopxWorkspaceLayout) -> LoopxGitCommandPlan { + LoopxGitCommandPlan { + executable: git.to_path_buf(), + args: vec![ + OsString::from("-C"), + layout.worktree_path.as_os_str().to_owned(), + OsString::from("checkout"), + OsString::from("-b"), + OsString::from(&layout.branch_name), + ], + environment: git_noninteractive_environment(), + current_dir: None, + } +} + +async fn read_workspace_marker( + layout: &LoopxWorkspaceLayout, + operation_id: &str, +) -> loopx_contract::LoopxHostResult { + let marker_path = layout + .worktree_path + .join(".git") + .join(WORKSPACE_MARKER_NAME); + let raw = tokio::fs::read(&marker_path).await.map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Conflict, + operation_id, + format!( + "existing workspace has no valid BitFun ownership marker: {error}; existing data was preserved" + ), + false, + ) + })?; + serde_json::from_slice(&raw).map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Conflict, + operation_id, + format!( + "existing workspace ownership marker is invalid: {error}; existing data was preserved" + ), + false, + ) + }) +} + +async fn write_workspace_marker( + layout: &LoopxWorkspaceLayout, + task_id: &str, + operation_id: &str, +) -> loopx_contract::LoopxHostResult<()> { + let marker = LoopxWorkspaceMarker { + schema_version: WORKSPACE_MARKER_SCHEMA, + repository_identity: layout.repository_identity.clone(), + repository_hash: layout.repository_hash.clone(), + task_id_hash: sha256_prefix(task_id.as_bytes(), 20), + branch_name: layout.branch_name.clone(), + }; + let git_dir = layout.worktree_path.join(".git"); + let marker_path = git_dir.join(WORKSPACE_MARKER_NAME); + let temporary_path = git_dir.join(format!("{WORKSPACE_MARKER_NAME}.tmp")); + let encoded = serde_json::to_vec_pretty(&marker).map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + operation_id, + error.to_string(), + false, + ) + })?; + tokio::fs::write(&temporary_path, encoded) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + operation_id, + format!("failed to write workspace ownership marker: {error}"), + true, + ) + })?; + tokio::fs::rename(&temporary_path, &marker_path) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + operation_id, + format!("failed to publish workspace ownership marker: {error}"), + true, + ) + })?; + Ok(()) +} + +fn validate_marker( + marker: &LoopxWorkspaceMarker, + layout: &LoopxWorkspaceLayout, + operation_id: &str, +) -> loopx_contract::LoopxHostResult<()> { + if marker.schema_version != WORKSPACE_MARKER_SCHEMA + || marker.repository_identity != layout.repository_identity + || marker.repository_hash != layout.repository_hash + || marker.task_id_hash != layout.task_hash + || marker.branch_name != layout.branch_name + { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::Conflict, + operation_id, + "existing workspace ownership does not match the requested task; existing data was preserved", + false, + )); + } + Ok(()) +} + +async fn ensure_path_boundary( + canonical_root: &Path, + path: &Path, + operation_id: &str, +) -> loopx_contract::LoopxHostResult<()> { + let canonical = tokio::fs::canonicalize(path).await.map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + operation_id, + format!("failed to resolve workspace path: {error}"), + true, + ) + })?; + if !canonical.starts_with(canonical_root) { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::Conflict, + operation_id, + "workspace path resolves outside the managed root", + false, + )); + } + Ok(()) +} + +fn validate_task_and_item( + operation_id: &str, + task_id: &str, + item: &loopx_contract::LoopxIssueKey, +) -> loopx_contract::LoopxHostResult<()> { + if operation_id.trim().is_empty() || task_id.trim().is_empty() { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::InvalidInput, + operation_id, + "operation_id and task_id are required", + false, + )); + } + if !item.repository.host.eq_ignore_ascii_case("github.com") + || !is_github_slug(&item.repository.owner) + || !is_github_slug(&item.repository.repository) + || item.number == 0 + { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::InvalidInput, + operation_id, + "workspace preparation requires a canonical GitHub item", + false, + )); + } + Ok(()) +} + +fn is_github_slug(value: &str) -> bool { + !value.is_empty() + && value.len() <= 100 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) +} + +fn canonical_github_path(path: &str) -> Option { + let path = path.strip_suffix(".git").unwrap_or(path); + let mut segments = path.split('/'); + let owner = segments.next()?; + let repository = segments.next()?; + if segments.next().is_some() || !is_github_slug(owner) || !is_github_slug(repository) { + return None; + } + Some(format!("github.com/{owner}/{repository}").to_lowercase()) +} + +fn sha256_prefix(value: &[u8], length: usize) -> String { + let digest = hex::encode(Sha256::digest(value)); + digest[..length].to_string() +} + +fn workspace_result( + layout: &LoopxWorkspaceLayout, + reused: bool, +) -> loopx_contract::LoopxWorkspacePrepareResult { + loopx_contract::LoopxWorkspacePrepareResult { + worktree_path: layout.worktree_path.to_string_lossy().into_owned(), + registry_path: layout.registry_path.to_string_lossy().into_owned(), + reused, + repository_verified: true, + } +} + +fn invalid_workspace(message: impl Into) -> loopx_contract::LoopxWorkspaceVerifyResult { + loopx_contract::LoopxWorkspaceVerifyResult { + valid: false, + repository: None, + message: Some(message.into()), + } +} + +fn map_process_error( + error: LoopxProcessError, + operation_id: &str, +) -> loopx_contract::LoopxHostPortError { + let (kind, retryable) = match &error { + LoopxProcessError::Cancelled { .. } => { + (loopx_contract::LoopxHostPortErrorKind::Cancelled, true) + } + LoopxProcessError::Timeout { .. } => { + (loopx_contract::LoopxHostPortErrorKind::Timeout, true) + } + LoopxProcessError::Io { .. } => (loopx_contract::LoopxHostPortErrorKind::Io, true), + _ => (loopx_contract::LoopxHostPortErrorKind::Backend, true), + }; + host_error( + kind, + operation_id, + format!("workspace git command failed: {error}"), + retryable, + ) +} + +fn host_error( + kind: loopx_contract::LoopxHostPortErrorKind, + operation_id: &str, + message: impl Into, + retryable: bool, +) -> loopx_contract::LoopxHostPortError { + loopx_contract::LoopxHostPortError { + kind, + message: message.into(), + operation_id: Some(operation_id.to_string()), + retryable, + } +} diff --git a/src/crates/services/services-integrations/src/miniapp/mod.rs b/src/crates/services/services-integrations/src/miniapp/mod.rs index 0b72d5f3ce..7901621009 100644 --- a/src/crates/services/services-integrations/src/miniapp/mod.rs +++ b/src/crates/services/services-integrations/src/miniapp/mod.rs @@ -1,7 +1,18 @@ //! MiniApp concrete integration services. +#[cfg(feature = "miniapp-runtime")] pub mod builtin_io; +#[cfg(feature = "miniapp-runtime")] pub mod host_dispatch; +#[cfg(feature = "miniapp-loopx")] +pub mod loopx_cli; +#[cfg(feature = "miniapp-loopx")] +pub mod loopx_github; +#[cfg(feature = "miniapp-loopx")] +pub mod loopx_workspace; +#[cfg(feature = "miniapp-runtime")] pub mod storage; +#[cfg(feature = "miniapp-runtime")] pub mod worker; +#[cfg(feature = "miniapp-runtime")] pub mod worker_pool; diff --git a/src/crates/services/services-integrations/src/miniapp/worker.rs b/src/crates/services/services-integrations/src/miniapp/worker.rs index 1c8ededb03..380928d906 100644 --- a/src/crates/services/services-integrations/src/miniapp/worker.rs +++ b/src/crates/services/services-integrations/src/miniapp/worker.rs @@ -18,6 +18,10 @@ type PendingResponseSender = oneshot::Sender; type PendingResponseMap = HashMap; pub type MiniAppWorkerEventFuture<'a> = Pin + Send + 'a>>; +/// How long to wait for the worker host's `__ready` handshake before treating +/// the spawn as failed. +const BOOT_READY_TIMEOUT: Duration = Duration::from_secs(10); + #[derive(Debug, Clone)] pub struct MiniAppWorkerEvent { pub app_id: String, @@ -40,11 +44,19 @@ pub struct JsWorker { } impl JsWorker { - /// Spawn Worker process: `runtime_path worker_host_path ''` with cwd = app_dir. + /// Spawn Worker process: `runtime_path worker_host_path` with cwd = app_dir. /// The `app_id` is used as the source identifier when emitting worker events. + /// `resource_dir`, when present, is exported as `BITFUN_RESOURCE_DIR` so + /// workers can find host-bundled sidecar binaries (e.g. the bitfun-loopx + /// compiled loopx CLI). + /// The permission policy JSON travels via the `BITFUN_WORKER_POLICY` + /// environment variable, not an argv argument: Windows argv parsing (and + /// Bun's in particular) does not round-trip multi-line JSON reliably, and + /// the worker fails to boot with a truncated policy. pub async fn spawn( runtime: &DetectedRuntime, worker_host_path: &Path, + resource_dir: Option<&Path>, app_dir: &Path, policy_json: &str, app_id: String, @@ -52,14 +64,19 @@ impl JsWorker { ) -> Result { let exe = runtime.path.to_string_lossy(); let host = worker_host_path.to_string_lossy(); - let mut child = bitfun_services_core::process_manager::create_tokio_command(&*exe) + let mut command = bitfun_services_core::process_manager::create_tokio_command(&*exe); + command .arg(&*host) - .arg(policy_json) .current_dir(app_dir) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .kill_on_drop(true) + .env("BITFUN_WORKER_POLICY", policy_json); + if let Some(dir) = resource_dir { + command.env("BITFUN_RESOURCE_DIR", dir); + } + let mut child = command .spawn() .map_err(|e| format!("Failed to spawn JS Worker: {}", e))?; @@ -75,10 +92,74 @@ impl JsWorker { .as_millis() as i64, )); + // Boot handshake: the worker host emits {"id":"__ready",...} on stderr + // once the app worker has loaded. Wait for it with a short deadline so + // a worker that crashes at startup (runtime misdetection, syntax error, + // missing dependency) fails fast instead of hanging every RPC until the + // caller's — possibly very large — timeout. + let deadline = std::time::Instant::now() + BOOT_READY_TIMEOUT; + let mut reader = BufReader::new(stderr); + let mut boot_log = String::new(); + let mut ready = false; + loop { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + break; + } + let mut line = String::new(); + let read = tokio::time::timeout(remaining, reader.read_line(&mut line)).await; + match read { + Ok(Ok(0)) => break, // EOF: the worker process is gone + Ok(Ok(_)) => { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if trimmed.contains("\"id\":\"__ready\"") + || trimmed.contains("\"id\": \"__ready\"") + { + ready = true; + break; + } + if boot_log.len() < 2048 { + boot_log.push_str(trimmed); + boot_log.push('\n'); + } + } + Ok(Err(err)) => { + boot_log.push_str(&format!("stderr read error: {err}\n")); + break; + } + Err(_) => break, // timed out waiting for the ready handshake + } + } + if !ready { + let _ = child.start_kill(); + let exit = tokio::time::timeout(Duration::from_secs(2), async { + loop { + if let Some(status) = child.try_wait().ok().flatten() { + return status.code(); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .ok() + .flatten(); + let detail = if boot_log.trim().is_empty() { + "worker exited without output".to_string() + } else { + boot_log.trim().to_string() + }; + return Err(format!( + "JS Worker failed to become ready (exit={:?}): {}", + exit, detail + )); + } + let pending_clone = pending.clone(); let last_activity_clone = last_activity.clone(); tokio::spawn(async move { - let reader = BufReader::new(stderr); let mut lines = reader.lines(); while let Ok(Some(line)) = lines.next_line().await { if line.is_empty() { diff --git a/src/crates/services/services-integrations/src/miniapp/worker_pool.rs b/src/crates/services/services-integrations/src/miniapp/worker_pool.rs index 2a6d91409a..d637a7df89 100644 --- a/src/crates/services/services-integrations/src/miniapp/worker_pool.rs +++ b/src/crates/services/services-integrations/src/miniapp/worker_pool.rs @@ -173,6 +173,9 @@ pub struct JsWorkerPool { runtime: DetectedRuntime, worker_host_path: PathBuf, miniapps_dir: PathBuf, + /// Directory of host-bundled sidecar resources, exported to every worker + /// as `BITFUN_RESOURCE_DIR` (None in host-less tests and minimal hosts). + resource_dir: Option, event_sink: Option, } @@ -180,6 +183,7 @@ impl JsWorkerPool { pub fn new( miniapps_dir: PathBuf, worker_host_path: PathBuf, + resource_dir: Option, event_sink: Option, ) -> MiniAppWorkerPoolResult { let runtime = detect_runtime().ok_or_else(|| { @@ -190,6 +194,7 @@ impl JsWorkerPool { Ok(Self::from_runtime( miniapps_dir, worker_host_path, + resource_dir, runtime, event_sink, )) @@ -198,6 +203,7 @@ impl JsWorkerPool { pub fn from_runtime( miniapps_dir: PathBuf, worker_host_path: PathBuf, + resource_dir: Option, runtime: DetectedRuntime, event_sink: Option, ) -> Self { @@ -209,6 +215,7 @@ impl JsWorkerPool { runtime, worker_host_path, miniapps_dir, + resource_dir, event_sink, } } @@ -308,6 +315,7 @@ impl JsWorkerPool { let worker = JsWorker::spawn( &self.runtime, &self.worker_host_path, + self.resource_dir.as_deref(), app_dir, policy_json, app_id.to_string(), @@ -537,6 +545,7 @@ mod tests { let pool = JsWorkerPool::from_runtime( miniapps_dir, PathBuf::from("worker-host.js"), + None, DetectedRuntime { kind: RuntimeKind::Node, path: PathBuf::from("node"), @@ -577,6 +586,7 @@ mod tests { let pool = JsWorkerPool::from_runtime( miniapps_dir, PathBuf::from("worker-host.js"), + None, DetectedRuntime { kind: RuntimeKind::Node, path: PathBuf::from("node"), diff --git a/src/crates/services/services-integrations/tests/miniapp_loopx_contracts.rs b/src/crates/services/services-integrations/tests/miniapp_loopx_contracts.rs new file mode 100644 index 0000000000..315ef98fb0 --- /dev/null +++ b/src/crates/services/services-integrations/tests/miniapp_loopx_contracts.rs @@ -0,0 +1,676 @@ +use async_trait::async_trait; +use bitfun_product_domains::miniapp::loopx::{ + LoopxCliCallContext, LoopxCliCreateGoalRequest, LoopxCliErrorKind, LoopxCliGoalContext, + LoopxCliHandshakeRequest, LoopxCliIntakePlan, LoopxCliPlanItemRequest, LoopxCliPort, + LoopxCliProgress, LoopxCliProgressSink, LoopxCliSource, LoopxCliTodoPlan, LoopxIssueKey, + LoopxItemKind, LoopxPermissionScope, LoopxRepositoryKey, LoopxWorkspacePort, + LoopxWorkspacePrepareRequest, +}; +use bitfun_services_integrations::miniapp::loopx_cli::{ + LoopxCliAdapterConfig, LoopxCliProcessAdapter, LoopxCommandPlan, LoopxCommandSource, + LoopxFixedCommandLocator, LoopxProcessError, LoopxProcessObserver, LoopxProcessOutput, + LoopxProcessProgress, LoopxProcessRunner, LoopxProgressStage, LoopxSystemFallbackPolicy, + NoopLoopxProcessObserver, SystemLoopxProcessRunner, LOOPX_COMMAND_REFERENCE_SCHEMA, +}; +use bitfun_services_integrations::miniapp::loopx_workspace::{ + canonical_github_remote, plan_git_clone_command, plan_workspace_layout, LoopxWorkspaceService, + LoopxWorkspaceServiceConfig, +}; +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, VecDeque}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tokio_util::sync::CancellationToken; + +#[derive(Default)] +struct RecordingProgressSink(Mutex>); + +impl LoopxCliProgressSink for RecordingProgressSink { + fn report(&self, progress: LoopxCliProgress) { + self.0.lock().unwrap().push(progress); + } +} + +#[derive(Default)] +struct RecordingProcessObserver(Mutex>); + +impl LoopxProcessObserver for RecordingProcessObserver { + fn on_progress(&self, progress: LoopxProcessProgress) { + self.0.lock().unwrap().push(progress); + } +} + +#[derive(Default)] +struct FakeRunner { + results: Mutex>>, + plans: Mutex>, +} + +impl FakeRunner { + fn with_results( + results: impl IntoIterator>, + ) -> Self { + Self { + results: Mutex::new(results.into_iter().collect()), + plans: Mutex::new(Vec::new()), + } + } + + fn plans(&self) -> Vec { + self.plans.lock().unwrap().clone() + } +} + +#[async_trait] +impl LoopxProcessRunner for FakeRunner { + async fn run( + &self, + plan: LoopxCommandPlan, + _cancellation: CancellationToken, + _observer: &dyn LoopxProcessObserver, + ) -> Result { + self.plans.lock().unwrap().push(plan); + self.results + .lock() + .unwrap() + .pop_front() + .expect("fake process result") + } +} + +#[derive(Default)] +struct WorkspaceFakeRunner { + plans: Mutex>, + remote: Mutex, +} + +impl WorkspaceFakeRunner { + fn new(remote: &str) -> Self { + Self { + plans: Mutex::new(Vec::new()), + remote: Mutex::new(remote.to_string()), + } + } +} + +#[async_trait] +impl LoopxProcessRunner for WorkspaceFakeRunner { + async fn run( + &self, + plan: LoopxCommandPlan, + _cancellation: CancellationToken, + _observer: &dyn LoopxProcessObserver, + ) -> Result { + if plan.args.first() == Some(&OsString::from("clone")) { + let target = PathBuf::from(plan.args.last().expect("clone target")); + std::fs::create_dir_all(target.join(".git")).unwrap(); + } + let stdout = if plan + .args + .windows(3) + .any(|args| args == ["config", "--get", "remote.origin.url"]) + { + format!("{}\n", self.remote.lock().unwrap()) + } else { + String::new() + }; + self.plans.lock().unwrap().push(plan); + Ok(LoopxProcessOutput { + stdout, + stderr_tail: Vec::new(), + elapsed: Duration::from_millis(1), + }) + } +} + +struct FakeLocator { + path: Option, + calls: AtomicUsize, +} + +impl FakeLocator { + fn new(path: Option) -> Self { + Self { + path, + calls: AtomicUsize::new(0), + } + } +} + +impl LoopxFixedCommandLocator for FakeLocator { + fn locate(&self) -> Result, String> { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(self.path.clone()) + } +} + +fn output(stdout: impl Into) -> Result { + Ok(LoopxProcessOutput { + stdout: stdout.into(), + stderr_tail: Vec::new(), + elapsed: Duration::from_millis(1), + }) +} + +fn stage_bundle(root: &Path, version: &str, schema: u32) -> PathBuf { + let bundle = root.join("loopx"); + std::fs::create_dir_all(&bundle).unwrap(); + let executable = bundle.join(if cfg!(windows) { "loopx.exe" } else { "loopx" }); + let bytes = b"test-only-loopx-binary"; + std::fs::write(&executable, bytes).unwrap(); + let digest = hex::encode(Sha256::digest(bytes)); + std::fs::write( + bundle.join("manifest.json"), + serde_json::to_vec_pretty(&json!({ + "schema_version": schema, + "name": "loopx", + "version": version, + "sha256": format!("sha256:{digest}"), + })) + .unwrap(), + ) + .unwrap(); + executable +} + +fn handshake_results( + version: &str, + schema: &str, +) -> Vec> { + vec![ + output(format!("{version}\n")), + output(json!({"ok": true, "schema_version": schema}).to_string()), + ] +} + +fn adapter_with_runner( + resource_dir: &Path, + runner: Arc, + locator: Arc, +) -> LoopxCliProcessAdapter { + let mut config = LoopxCliAdapterConfig::packaged(resource_dir); + config.system_fallback = LoopxSystemFallbackPolicy::ExactPinned; + config.startup_deadline = Duration::from_secs(3); + config.command_deadline = Duration::from_secs(9); + LoopxCliProcessAdapter::with_dependencies( + config, + runner, + locator, + Arc::new(NoopLoopxProcessObserver), + ) +} + +fn handshake_request(operation_id: &str) -> LoopxCliHandshakeRequest { + LoopxCliHandshakeRequest { + call: LoopxCliCallContext { + operation_id: operation_id.to_string(), + deadline_at: None, + }, + ..LoopxCliHandshakeRequest::default() + } +} + +#[test] +fn packaged_startup_budget_covers_measured_windows_onefile_cold_start() { + let config = LoopxCliAdapterConfig::packaged(PathBuf::from("resources")); + assert!(config.startup_deadline >= Duration::from_secs(60)); + assert_eq!(config.command_deadline, Duration::from_secs(180)); +} + +#[tokio::test] +async fn packaged_bundle_is_preferred_and_exactly_handshaken() { + let temporary = tempfile::tempdir().unwrap(); + let bundled = stage_bundle(temporary.path(), "v0.2.13", 1); + let runner = Arc::new(FakeRunner::with_results(handshake_results( + "loopx 0.2.13", + LOOPX_COMMAND_REFERENCE_SCHEMA, + ))); + let locator = Arc::new(FakeLocator::new(Some(PathBuf::from("system-loopx")))); + let adapter = adapter_with_runner(temporary.path(), runner.clone(), locator.clone()); + + let manifest = adapter + .handshake( + handshake_request("handshake-bundle"), + &RecordingProgressSink::default(), + ) + .await + .unwrap(); + + assert_eq!(manifest.executable.source, LoopxCliSource::Bundled); + assert_eq!(manifest.loopx_version, "0.2.13"); + assert_eq!(manifest.schema_version, 1); + assert_eq!( + manifest.executable.path.as_deref(), + Some(bundled.to_string_lossy().as_ref()) + ); + assert!(manifest + .executable + .sha256 + .as_deref() + .unwrap() + .starts_with("sha256:")); + assert_eq!(locator.calls.load(Ordering::SeqCst), 0); + let plans = runner.plans(); + assert_eq!(plans.len(), 2); + assert_eq!(plans[0].executable, bundled); + assert_eq!(plans[0].args, vec![OsString::from("--version")]); + assert_eq!( + plans[1].args, + ["--format", "json", "commands"] + .into_iter() + .map(OsString::from) + .collect::>() + ); +} + +#[tokio::test] +async fn runtime_version_mismatch_is_a_non_retryable_typed_error() { + let temporary = tempfile::tempdir().unwrap(); + stage_bundle(temporary.path(), "v0.2.13", 1); + let runner = Arc::new(FakeRunner::with_results([output("loopx 0.2.12\n")])); + let adapter = adapter_with_runner(temporary.path(), runner, Arc::new(FakeLocator::new(None))); + + let error = adapter + .handshake( + handshake_request("handshake-version"), + &RecordingProgressSink::default(), + ) + .await + .unwrap_err(); + + assert_eq!(error.kind, LoopxCliErrorKind::VersionMismatch); + assert!(!error.retryable); +} + +#[tokio::test] +async fn command_reference_schema_mismatch_is_rejected() { + let temporary = tempfile::tempdir().unwrap(); + stage_bundle(temporary.path(), "v0.2.13", 1); + let runner = Arc::new(FakeRunner::with_results(handshake_results( + "loopx 0.2.13", + "future_schema_v99", + ))); + let adapter = adapter_with_runner(temporary.path(), runner, Arc::new(FakeLocator::new(None))); + + let error = adapter + .handshake( + handshake_request("handshake-schema"), + &RecordingProgressSink::default(), + ) + .await + .unwrap_err(); + + assert_eq!(error.kind, LoopxCliErrorKind::SchemaMismatch); +} + +#[tokio::test] +async fn item_plan_uses_structured_registry_and_worktree_arguments() { + let temporary = tempfile::tempdir().unwrap(); + stage_bundle(temporary.path(), "v0.2.13", 1); + let worktree = temporary.path().join("worktree"); + std::fs::create_dir_all(&worktree).unwrap(); + let registry = worktree.join(".loopx").join("registry.json"); + let workflow = json!({ + "ok": true, + "schema_version": "issue_fix_workflow_plan_packet_v0", + "objective": "Fix issue 42", + "ordered_loopx_todo_writeback_preview": [{ + "role": "agent", + "task_class": "advancement_task", + "action_kind": "fix_issue", + "text": "[P1] Fix issue #42" + }] + }); + let runner = Arc::new(FakeRunner::with_results( + handshake_results("loopx 0.2.13", LOOPX_COMMAND_REFERENCE_SCHEMA) + .into_iter() + .chain([output(workflow.to_string())]), + )); + let adapter = adapter_with_runner( + temporary.path(), + runner.clone(), + Arc::new(FakeLocator::new(None)), + ); + let item = LoopxIssueKey { + repository: LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "owner".to_string(), + repository: "repo".to_string(), + }, + kind: LoopxItemKind::Issue, + number: 42, + }; + let request = LoopxCliPlanItemRequest { + context: LoopxCliGoalContext { + call: LoopxCliCallContext { + operation_id: "plan-item".to_string(), + deadline_at: None, + }, + task_id: "task-42".to_string(), + generation: 1, + worktree_path: worktree.to_string_lossy().into_owned(), + registry_path: registry.to_string_lossy().into_owned(), + }, + item, + }; + + let plan = adapter + .plan_item(request, &RecordingProgressSink::default()) + .await + .unwrap(); + + assert_eq!(plan.objective, "Fix issue 42"); + assert_eq!(plan.todos.len(), 1); + let command = runner.plans().pop().unwrap(); + assert_eq!(command.current_dir.as_deref(), Some(worktree.as_path())); + assert!(command.environment.is_empty()); + assert_eq!(command.deadline, Duration::from_secs(9)); + assert_eq!( + command.args, + vec![ + OsString::from("--format"), + OsString::from("json"), + OsString::from("--registry"), + registry.as_os_str().to_owned(), + OsString::from("issue-fix"), + OsString::from("workflow-plan"), + OsString::from("--url"), + OsString::from("https://github.com/owner/repo/issues/42"), + OsString::from("--repo-path"), + worktree.as_os_str().to_owned(), + OsString::from("--fetch-metadata"), + OsString::from("--fetch-timeout-seconds"), + OsString::from("20"), + ] + ); +} + +#[tokio::test] +async fn create_goal_recovery_does_not_duplicate_an_existing_planned_todo() { + let temporary = tempfile::tempdir().unwrap(); + stage_bundle(temporary.path(), "v0.2.13", 1); + let worktree = temporary.path().join("worktree"); + std::fs::create_dir_all(&worktree).unwrap(); + let registry = worktree.join(".loopx").join("registry.json"); + let planned_todo = LoopxCliTodoPlan { + role: "agent".to_string(), + task_class: "advancement_task".to_string(), + action_kind: Some("fix_issue".to_string()), + text: "[P1] Fix issue #42".to_string(), + }; + let results = handshake_results("loopx 0.2.13", LOOPX_COMMAND_REFERENCE_SCHEMA) + .into_iter() + .chain([ + output(json!({"ok": true, "state_action": "kept"}).to_string()), + output(json!({"ok": true}).to_string()), + output( + json!({ + "ok": true, + "todos": [{ + "role": planned_todo.role.clone(), + "task_class": planned_todo.task_class.clone(), + "action_kind": planned_todo.action_kind.clone(), + "text": planned_todo.text.clone(), + }] + }) + .to_string(), + ), + output( + json!({ + "ok": true, + "schema_version": "loopx_turn_plan_v0", + "turn_envelope": { + "action_signature": {"source_decision_hash": "sha256:durable-revision"} + }, + "transaction": {"turn_key": "sha256:turn"} + }) + .to_string(), + ), + ]); + let runner = Arc::new(FakeRunner::with_results(results)); + let adapter = adapter_with_runner( + temporary.path(), + runner.clone(), + Arc::new(FakeLocator::new(None)), + ); + let item = LoopxIssueKey { + repository: LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "owner".to_string(), + repository: "repo".to_string(), + }, + kind: LoopxItemKind::Issue, + number: 42, + }; + let request = LoopxCliCreateGoalRequest { + context: LoopxCliGoalContext { + call: LoopxCliCallContext { + operation_id: "create-recovery".to_string(), + deadline_at: None, + }, + task_id: "task-42".to_string(), + generation: 2, + worktree_path: worktree.to_string_lossy().into_owned(), + registry_path: registry.to_string_lossy().into_owned(), + }, + goal_id: "goal-42".to_string(), + agent_id: "bitfun-agent".to_string(), + intake: LoopxCliIntakePlan { + item, + objective: "Fix issue 42".to_string(), + todos: vec![planned_todo], + }, + granted_scopes: vec![LoopxPermissionScope::WorkspaceWrite], + }; + + let result = adapter + .create_goal(request, &RecordingProgressSink::default()) + .await + .unwrap(); + + assert!(!result.created); + assert_eq!(result.durable_revision, "sha256:durable-revision"); + let commands = runner + .plans() + .into_iter() + .skip(2) + .map(|plan| plan.args) + .collect::>(); + assert_eq!(commands.len(), 4); + assert!(!commands.iter().any(|args| { + args.windows(2) + .any(|pair| pair[0] == OsString::from("todo") && pair[1] == OsString::from("add")) + })); +} + +#[test] +fn workspace_plan_uses_hashed_paths_and_noninteractive_structured_clone() { + let root = if cfg!(windows) { + PathBuf::from(r"C:\managed-loopx") + } else { + PathBuf::from("/managed-loopx") + }; + let item = LoopxIssueKey { + repository: LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "Owner".to_string(), + repository: "Repo".to_string(), + }, + kind: LoopxItemKind::Issue, + number: 42, + }; + + let layout = plan_workspace_layout(&root, "task-sensitive-name", &item).unwrap(); + let command = plan_git_clone_command(Path::new("git"), &layout); + + assert!(layout.worktree_path.starts_with(&root)); + assert!(!layout.worktree_path.to_string_lossy().contains("Owner")); + assert!(layout + .registry_path + .ends_with(Path::new(".loopx/registry.json"))); + assert_eq!(command.args[0], OsString::from("clone")); + assert_eq!(command.args[1], OsString::from("--no-checkout")); + assert!(command.args.contains(&OsString::from("--"))); + assert_eq!( + command + .environment + .get(&OsString::from("GIT_TERMINAL_PROMPT")), + Some(&OsString::from("0")) + ); + assert_eq!( + canonical_github_remote("git@github.com:OWNER/Repo.git").as_deref(), + Some("github.com/owner/repo") + ); +} + +#[tokio::test] +async fn workspace_prepare_clones_once_then_reuses_verified_marker_and_origin() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("loopx-workspaces"); + let runner = Arc::new(WorkspaceFakeRunner::new( + "https://github.com/owner/repo.git", + )); + let mut config = LoopxWorkspaceServiceConfig::new(&root, "git"); + config.clone_deadline = Duration::from_secs(7); + config.command_deadline = Duration::from_secs(3); + let service = LoopxWorkspaceService::with_runner( + config, + runner.clone(), + Arc::new(NoopLoopxProcessObserver), + ); + let item = LoopxIssueKey { + repository: LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "owner".to_string(), + repository: "repo".to_string(), + }, + kind: LoopxItemKind::Issue, + number: 42, + }; + let request = LoopxWorkspacePrepareRequest { + operation_id: "workspace-first".to_string(), + task_id: "task-42".to_string(), + item: item.clone(), + }; + + let created = service.prepare(request).await.unwrap(); + let reused = service + .prepare(LoopxWorkspacePrepareRequest { + operation_id: "workspace-second".to_string(), + task_id: "task-42".to_string(), + item, + }) + .await + .unwrap(); + + assert!(!created.reused); + assert!(created.repository_verified); + assert!(reused.reused); + assert_eq!(created.worktree_path, reused.worktree_path); + assert!(Path::new(&created.registry_path).ends_with(Path::new(".loopx/registry.json"))); + let plans = runner.plans.lock().unwrap(); + assert_eq!(plans.len(), 4); + assert_eq!(plans[0].deadline, Duration::from_secs(7)); + assert_eq!(plans[1].deadline, Duration::from_secs(3)); + assert_eq!(plans[2].deadline, Duration::from_secs(3)); + assert_eq!(plans[3].deadline, Duration::from_secs(3)); +} + +#[test] +fn managed_process_fixture() { + match std::env::var("BITFUN_LOOPX_PROCESS_FIXTURE").as_deref() { + Ok("exit") => std::process::exit(23), + Ok("sleep") => std::thread::sleep(Duration::from_secs(60)), + Ok("stderr") => eprintln!("fixture progress line"), + _ => {} + } +} + +fn fixture_plan(kind: &str, deadline: Duration) -> LoopxCommandPlan { + let mut environment = BTreeMap::new(); + environment.insert( + OsString::from("BITFUN_LOOPX_PROCESS_FIXTURE"), + OsString::from(kind), + ); + LoopxCommandPlan { + operation_id: format!("fixture-{kind}"), + executable: std::env::current_exe().unwrap(), + args: ["--exact", "managed_process_fixture", "--nocapture"] + .into_iter() + .map(OsString::from) + .collect(), + current_dir: None, + environment, + deadline, + terminate_grace: Duration::from_millis(50), + } +} + +#[tokio::test] +async fn child_exit_is_reported_immediately() { + let started = Instant::now(); + let error = SystemLoopxProcessRunner + .run( + fixture_plan("exit", Duration::from_secs(5)), + CancellationToken::new(), + &NoopLoopxProcessObserver, + ) + .await + .unwrap_err(); + + assert!(started.elapsed() < Duration::from_secs(2)); + assert!(matches!( + error, + LoopxProcessError::Exited { code: Some(23), .. } + )); +} + +#[tokio::test] +async fn deadline_terminates_the_managed_process_tree() { + let started = Instant::now(); + let error = SystemLoopxProcessRunner + .run( + fixture_plan("sleep", Duration::from_millis(50)), + CancellationToken::new(), + &NoopLoopxProcessObserver, + ) + .await + .unwrap_err(); + + assert!(started.elapsed() < Duration::from_secs(2)); + assert!(matches!(error, LoopxProcessError::Timeout { .. })); +} + +#[tokio::test] +async fn stderr_is_streamed_as_progress_before_success() { + let observer = RecordingProcessObserver::default(); + SystemLoopxProcessRunner + .run( + fixture_plan("stderr", Duration::from_secs(5)), + CancellationToken::new(), + &observer, + ) + .await + .unwrap(); + + let progress = observer.0.lock().unwrap(); + assert!(progress.iter().any(|event| { + event.stage == LoopxProgressStage::Stderr && event.message.contains("fixture progress line") + })); + assert_eq!( + progress.last().map(|event| event.stage), + Some(LoopxProgressStage::Exited) + ); +} + +#[test] +fn packaged_source_enum_stays_distinct_from_system_fallback() { + assert_ne!( + LoopxCommandSource::PackagedBundle, + LoopxCommandSource::FixedSystemCommand + ); +} diff --git a/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.scss b/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.scss index dbc950e04c..8272b12aba 100644 --- a/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.scss +++ b/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.scss @@ -40,6 +40,36 @@ $scene-gutter: clamp(28px, 4vw, 64px); min-height: 0; } } + + // Non-desktop surfaces (server/web): explicit degrade notice instead of an + // empty gallery, telling the user where MiniApps actually run. + &__unsupported { + display: grid; + place-items: center; + flex: 1 1 auto; + min-height: 0; + padding: $size-gap-3; + background: var(--bf-appearance-token-color-bg-primary); + } + + &__unsupported-card { + max-width: 440px; + padding: $size-gap-4; + border: 1px solid var(--bf-appearance-token-border-subtle); + border-radius: $size-radius-base; + text-align: center; + } + + &__unsupported-title { + margin: 0 0 $size-gap-1; + font-size: 15px; + color: var(--bf-appearance-token-color-text-primary); + } + + &__unsupported-body { + margin: 0; + color: var(--bf-appearance-token-color-text-secondary); + } } @media (max-width: 560px) { diff --git a/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.tsx b/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.tsx index 448a695c80..03411df1c7 100644 --- a/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.tsx +++ b/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.tsx @@ -6,6 +6,7 @@ import React, { Suspense, lazy, useState } from 'react'; import { Download, Store, UploadCloud } from 'lucide-react'; import { Tabs, TabPane } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; +import { isTauriRuntime } from '@/infrastructure/runtime'; import './MiniAppGalleryScene.scss'; const MiniAppGalleryView = lazy(() => import('./views/MiniAppGalleryView')); @@ -18,6 +19,22 @@ const MiniAppGalleryScene: React.FC = () => { const { t } = useI18n('scenes/miniapp'); const [activeTab, setActiveTab] = useState('installed'); + // MiniApps run inside the desktop host (worker pool + built-in seeding live + // there). On server/web surfaces the scene degrades loudly instead of + // rendering an empty gallery. + if (!isTauriRuntime()) { + return ( +
    +
    +
    +

    {t('unsupported.title')}

    +

    {t('unsupported.body')}

    +
    +
    +
    + ); + } + return (
    (['issue', 'pr']); +const PERMISSION_SCOPES = new Set([ + 'workspace_read', + 'workspace_write', + 'git_local', + 'github_read', + 'agent_execution', + 'publish', + 'public_comment', + 'pull_request', + 'merge', + 'production_action', +]); +const ACTION_KINDS = new Set([ + 'pause', + 'resume', + 'approve', + 'reject', + 'archive', + 'restore', + 'retry_environment', +]); + +function normalizedKey(key: string): string { + return key.replace(/_/g, '').toLowerCase(); +} + +function assertNoHostControlledFields(value: unknown, path = 'params'): void { + if (Array.isArray(value)) { + value.forEach((item, index) => assertNoHostControlledFields(item, `${path}[${index}]`)); + return; + } + if (!value || typeof value !== 'object') return; + + for (const [key, child] of Object.entries(value)) { + if (HOST_CONTROLLED_KEYS.has(normalizedKey(key))) { + throw new Error( + `LoopX MiniApps cannot provide host-controlled field '${path}.${key}'. ` + + 'Execution targets, filesystem paths, and CLI arguments are selected by the host.', + ); + } + assertNoHostControlledFields(child, `${path}.${key}`); + } +} + +function assertAllowedKeys( + value: Record, + allowed: readonly string[], + path: string, +): void { + const allowedSet = new Set(allowed); + const unsupported = Object.keys(value).find((key) => !allowedSet.has(key)); + if (unsupported) { + throw new Error(`Unsupported LoopX parameter '${path}.${unsupported}'.`); + } +} + +function asRecord(value: unknown, path: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`LoopX parameter '${path}' must be an object.`); + } + return value as Record; +} + +function requiredString(value: unknown, path: string): string { + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`LoopX parameter '${path}' must be a non-empty string.`); + } + return value; +} + +function optionalString(value: unknown, path: string): string | undefined { + if (value == null) return undefined; + return requiredString(value, path); +} + +function unsignedInteger(value: unknown, path: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`LoopX parameter '${path}' must be a non-negative safe integer.`); + } + return value; +} + +function optionalUnsignedInteger(value: unknown, path: string): number | undefined { + if (value == null) return undefined; + return unsignedInteger(value, path); +} + +function requiredBoolean(value: unknown, path: string): boolean { + if (typeof value !== 'boolean') { + throw new Error(`LoopX parameter '${path}' must be a boolean.`); + } + return value; +} + +function parseIssueKey(value: unknown, path: string): LoopxIssueKey { + const item = asRecord(value, path); + assertAllowedKeys(item, ['repository', 'kind', 'number'], path); + const repository = asRecord(item.repository, `${path}.repository`); + assertAllowedKeys(repository, ['host', 'owner', 'repository'], `${path}.repository`); + + const kind = item.kind; + if (typeof kind !== 'string' || !ITEM_KINDS.has(kind as LoopxItemKind)) { + throw new Error(`LoopX parameter '${path}.kind' must be 'issue' or 'pr'.`); + } + + return { + repository: { + host: requiredString(repository.host, `${path}.repository.host`), + owner: requiredString(repository.owner, `${path}.repository.owner`), + repository: requiredString(repository.repository, `${path}.repository.repository`), + }, + kind: kind as LoopxItemKind, + number: unsignedInteger(item.number, `${path}.number`), + }; +} + +function parsePermissionScopes(value: unknown): LoopxPermissionScope[] { + if (!Array.isArray(value)) { + throw new Error("LoopX parameter 'params.grantedScopes' must be an array."); + } + return value.map((scope, index) => { + if (typeof scope !== 'string' || !PERMISSION_SCOPES.has(scope as LoopxPermissionScope)) { + throw new Error(`Unsupported LoopX permission scope at 'params.grantedScopes[${index}]'.`); + } + return scope as LoopxPermissionScope; + }); +} + +export function isLoopxBridgeMethod(method: string): boolean { + return method.startsWith('loopx.'); +} + +export function parseLoopxBridgeCall( + method: string, + rawParams: Record, +): LoopxBridgeCall { + if (!LOOPX_METHODS.has(method)) { + throw new Error(`Unknown LoopX method: ${method}`); + } + assertNoHostControlledFields(rawParams); + + if (method === 'loopx.attach') { + assertAllowedKeys(rawParams, ['knownStreamId', 'afterCursor'], 'params'); + return { + kind: 'attach', + request: { + knownStreamId: optionalString(rawParams.knownStreamId, 'params.knownStreamId'), + afterCursor: optionalUnsignedInteger(rawParams.afterCursor, 'params.afterCursor'), + }, + }; + } + + if (method === 'loopx.listModels') { + assertAllowedKeys(rawParams, [], 'params'); + return { kind: 'listModels' }; + } + + if (method === 'loopx.resolveIntake') { + assertAllowedKeys(rawParams, ['input', 'modelId'], 'params'); + return { + kind: 'resolveIntake', + request: { + input: requiredString(rawParams.input, 'params.input'), + modelId: requiredString(rawParams.modelId, 'params.modelId'), + }, + }; + } + + if (method === 'loopx.createTask') { + assertAllowedKeys(rawParams, [ + 'clientRequestId', + 'previewFingerprint', + 'selectedItems', + 'modelId', + 'grantedScopes', + 'retryTerminal', + ], 'params'); + if (!Array.isArray(rawParams.selectedItems) || rawParams.selectedItems.length === 0) { + throw new Error("LoopX parameter 'params.selectedItems' must contain at least one item."); + } + return { + kind: 'createTask', + request: { + clientRequestId: requiredString(rawParams.clientRequestId, 'params.clientRequestId'), + previewFingerprint: requiredString( + rawParams.previewFingerprint, + 'params.previewFingerprint', + ), + selectedItems: rawParams.selectedItems.map((item, index) => + parseIssueKey(item, `params.selectedItems[${index}]`)), + modelId: requiredString(rawParams.modelId, 'params.modelId'), + grantedScopes: parsePermissionScopes(rawParams.grantedScopes), + retryTerminal: requiredBoolean(rawParams.retryTerminal, 'params.retryTerminal'), + }, + }; + } + + if (method === 'loopx.action') { + assertAllowedKeys(rawParams, [ + 'taskId', + 'action', + 'clientRequestId', + 'expectedRevision', + 'gateId', + 'note', + ], 'params'); + if ( + typeof rawParams.action !== 'string' + || !ACTION_KINDS.has(rawParams.action as LoopxActionKind) + ) { + throw new Error("Unsupported LoopX action at 'params.action'."); + } + return { + kind: 'action', + request: { + taskId: optionalString(rawParams.taskId, 'params.taskId'), + action: rawParams.action as LoopxActionKind, + clientRequestId: requiredString(rawParams.clientRequestId, 'params.clientRequestId'), + expectedRevision: unsignedInteger(rawParams.expectedRevision, 'params.expectedRevision'), + gateId: optionalString(rawParams.gateId, 'params.gateId'), + note: optionalString(rawParams.note, 'params.note'), + }, + }; + } + + assertAllowedKeys(rawParams, ['streamId', 'afterCursor', 'limit'], 'params'); + return { + kind: 'eventsSince', + request: { + streamId: requiredString(rawParams.streamId, 'params.streamId'), + afterCursor: unsignedInteger(rawParams.afterCursor, 'params.afterCursor'), + limit: optionalUnsignedInteger(rawParams.limit, 'params.limit'), + }, + }; +} diff --git a/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.test.tsx b/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.test.tsx index 4920ffcef3..93f35e059d 100644 --- a/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.test.tsx +++ b/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.test.tsx @@ -17,8 +17,16 @@ import { useMiniAppBridge } from './useMiniAppBridge'; const mocks = vi.hoisted(() => ({ activeTabId: 'miniapp:market-lens', + peerModeActive: false, + workspaceKind: undefined as 'remote' | undefined, agentEnsureSession: vi.fn(), agentRun: vi.fn(), + getCustomizationMetadata: vi.fn(), + loopxAttach: vi.fn(), + loopxResolveIntake: vi.fn(), + loopxCreateTask: vi.fn(), + loopxAction: vi.fn(), + loopxEventsSince: vi.fn(), apiListen: vi.fn(), openMainSession: vi.fn(), addExternalSession: vi.fn(), @@ -29,6 +37,12 @@ vi.mock('@/infrastructure/api/service-api/MiniAppAPI', () => ({ miniAppAPI: { agentEnsureSession: mocks.agentEnsureSession, agentRun: mocks.agentRun, + getCustomizationMetadata: mocks.getCustomizationMetadata, + loopxAttach: mocks.loopxAttach, + loopxResolveIntake: mocks.loopxResolveIntake, + loopxCreateTask: mocks.loopxCreateTask, + loopxAction: mocks.loopxAction, + loopxEventsSince: mocks.loopxEventsSince, }, })); @@ -39,7 +53,14 @@ vi.mock('@tauri-apps/plugin-dialog', () => ({ })); vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ - useCurrentWorkspace: () => ({ workspacePath: '/repo' }), + useCurrentWorkspace: () => ({ + workspacePath: '/repo', + workspace: mocks.workspaceKind ? { workspaceKind: mocks.workspaceKind } : null, + }), +})); + +vi.mock('@/infrastructure/peer-device/peerModeFlag', () => ({ + isPeerDeviceModeActive: () => mocks.peerModeActive, })); vi.mock('@/infrastructure/theme/hooks/useTheme', () => ({ @@ -115,6 +136,23 @@ function BridgeHarness() { return