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..e6ab6c3257 --- /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. +export const LOOPX_VERSION = process.env.LOOPX_VERSION || 'v0.5.1'; +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..f576a984af 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,38 @@ 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. Desktop dev keeps the same layout via ensureLoopxSidecar +// in scripts/dev.cjs; when the sidecar is absent the runtime degrades to the +// fixed system `loopx` command (ExactPinned) instead of vendor/pip. +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/scripts/dev.cjs b/scripts/dev.cjs index 07cf156307..070fd4af4a 100644 --- a/scripts/dev.cjs +++ b/scripts/dev.cjs @@ -618,6 +618,55 @@ async function ensureFlashgrepBundleResource() { return helper.ensureFlashgrepBinary(); } +/** + * Ensure the bundled, compiled loopx CLI sidecar exists for desktop dev. + * + * The runtime prefers this sidecar (`CARGO_MANIFEST_DIR/resources/loopx`, + * see desktop app_state::resolve_bundled_loopx_dir), so desktop:dev mirrors + * the packaging build instead of silently falling back to a system `loopx` + * command. The staged manifest.json carries the exact pin and a sha256 of the + * binary: when both match (and the checksum is intact) the build is skipped in + * seconds; a pin change or corruption triggers a rebuild. A build failure is a + * warning, never a dev-start blocker — the existing system-command fallback in + * loopx_cli.rs stays as the degraded path. + */ +async function ensureLoopxSidecar() { + const helperUrl = pathToFileURL(path.join(__dirname, 'build-loopx.mjs')).href; + const helper = await import(helperUrl); + const loopxDir = path.join(ROOT_DIR, 'src', 'apps', 'desktop', 'resources', 'loopx'); + const manifestPath = path.join(loopxDir, 'manifest.json'); + const binaryName = process.platform === 'win32' ? 'loopx.exe' : 'loopx'; + const binaryPath = path.join(loopxDir, binaryName); + + try { + if (fs.existsSync(manifestPath) && fs.existsSync(binaryPath)) { + const { createHash } = require('node:crypto'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const expected = (manifest.sha256 || '').replace(/^sha256:/, ''); + const actual = createHash('sha256').update(fs.readFileSync(binaryPath)).digest('hex'); + if (manifest.version === helper.LOOPX_VERSION && expected && actual === expected) { + printInfo( + `loopx sidecar up to date (v${helper.LOOPX_VERSION}${manifest.commit ? ` @ ${manifest.commit}` : ''})` + ); + return { ok: true, code: 0, error: null }; + } + printInfo('loopx sidecar pin or checksum changed; rebuilding...'); + } else { + printInfo( + 'loopx sidecar missing; building bundled CLI (first desktop:dev run may take a while)...' + ); + } + await helper.buildLoopx(); + printSuccess(`loopx sidecar ready (v${helper.LOOPX_VERSION})`); + return { ok: true, code: 0, error: null }; + } catch (error) { + printWarning( + `loopx sidecar build skipped (${error.message}); dev will fall back to a system loopx command` + ); + return { ok: true, code: 0, error: null }; + } +} + /** * Main entry */ @@ -656,7 +705,7 @@ async function main() { currentStep++, totalSteps, desktopMode - ? 'Prepare resources (parallel: monaco, version, mobile-web, flashgrep)' + ? 'Prepare resources (parallel: monaco, version, mobile-web, flashgrep, loopx)' : 'Prepare resources (parallel: monaco, version)' ); @@ -681,6 +730,10 @@ async function main() { name: 'Build mobile-web', promise: runCommandPrefixed('mobile-web', 'node', ['scripts/mobile-web-build.cjs', '--install']), }); + prepTasks.push({ + name: 'Prepare loopx CLI sidecar', + promise: ensureLoopxSidecar(), + }); prepTasks.push({ name: 'Prepare workspace search daemon', promise: (async () => { diff --git a/src/apps/cli/src/peer_host/deny.rs b/src/apps/cli/src/peer_host/deny.rs index 16701bec1c..ad74311e57 100644 --- a/src/apps/cli/src/peer_host/deny.rs +++ b/src/apps/cli/src/peer_host/deny.rs @@ -113,6 +113,13 @@ 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", + "miniapp_loopx_turn_output_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..80f18fc4ff --- /dev/null +++ b/src/apps/desktop/src/api/miniapp_loopx_api.rs @@ -0,0 +1,269 @@ +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, LoopxTurnOutputSinceRequest, + LoopxTurnOutputSinceResponse, 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 MiniAppLoopxTurnOutputSinceRequest { + pub app_id: String, + #[serde(flatten)] + pub input: LoopxTurnOutputSinceRequest, +} + +#[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) +} + +#[tauri::command] +pub async fn miniapp_loopx_turn_output_since( + app_state: State<'_, AppState>, + controller: State<'_, LoopxControllerState>, + request: MiniAppLoopxTurnOutputSinceRequest, +) -> Result { + authorize_builtin(&app_state, &request.app_id).await?; + if is_remote_workspace(&app_state).await { + return Err(unsupported_error()); + } + Ok(controller.controller.turn_output_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..ad25145a63 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -35,6 +35,7 @@ pub mod mcp_api; pub mod miniapp_agent_api; pub mod miniapp_api; pub mod miniapp_export_api; +pub mod miniapp_loopx_api; pub mod miniapp_market_api; pub mod pages_api; pub mod path_target; diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index fcc2800946..9a0cd12a30 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -145,6 +145,15 @@ 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", + "miniapp_loopx_turn_output_since", ]; static PENDING: OnceLock>>> = diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index 3d7a05134d..66881b866f 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -1283,11 +1283,10 @@ async fn register_delegated_identity_providers() { let account_lease = lock_account_sync(generation) .await .map_err(|_| "Desktop account changed; try again".to_string())?; - let context = account_context - .read() - .await - .clone() - .ok_or_else(|| "Desktop is not logged into a BitFun account".to_string())?; + let context = + account_context.read().await.clone().ok_or_else(|| { + "Desktop is not logged into a BitFun account".to_string() + })?; if !account_context_matches(generation, &context.session.token).await { return Err("Desktop account changed; try again".to_string()); } diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 54c2c63673..2949273b5d 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -1265,6 +1265,34 @@ 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_turn_output_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/api/ssh_api.rs b/src/apps/desktop/src/api/ssh_api.rs index 2d72ed00eb..779bf38207 100644 --- a/src/apps/desktop/src/api/ssh_api.rs +++ b/src/apps/desktop/src/api/ssh_api.rs @@ -540,8 +540,7 @@ fn validate_remote_name_for_local_download(name: &str) -> Result<(), String> { fn local_download_name_key(name: &str) -> String { #[cfg(any(windows, target_os = "macos"))] { - name.trim_end_matches(['.', ' ']) - .to_lowercase() + name.trim_end_matches(['.', ' ']).to_lowercase() } #[cfg(not(any(windows, target_os = "macos")))] { diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 96be3c3f1e..875fd6d1c6 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -695,6 +695,83 @@ 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( + // Prefer a short home-based root: target repositories can + // contain paths near the Windows MAX_PATH limit, and a + // deep AppData prefix made worktree checkouts fail with + // "Filename too long" -> "Could not reset index file". + dirs::home_dir() + .map(|home| home.join(".bitfun").join("loopx-workspaces")) + .unwrap_or_else(|| { + 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 +815,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 +844,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 +1930,13 @@ 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_loopx_api::miniapp_loopx_turn_output_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/src/sleep_prevention.rs b/src/apps/desktop/src/sleep_prevention.rs index b5a08957f6..64bc969b57 100644 --- a/src/apps/desktop/src/sleep_prevention.rs +++ b/src/apps/desktop/src/sleep_prevention.rs @@ -212,7 +212,10 @@ where error, rollback_error )); } - Err(format!("Failed to save prevent-sleep preference: {}", error)) + Err(format!( + "Failed to save prevent-sleep preference: {}", + error + )) } /// Applies the saved preference at startup and after config imports/reloads. @@ -412,7 +415,9 @@ mod tests { #[test] fn config_reload_re_reads_the_preference() { - assert!(config_event_requires_sync(&ConfigUpdateEvent::ConfigReloaded)); + assert!(config_event_requires_sync( + &ConfigUpdateEvent::ConfigReloaded + )); assert!(config_event_requires_sync(&ConfigUpdateEvent::AppUpdated)); assert!(!config_event_requires_sync( &ConfigUpdateEvent::ModelConfigurationUpdated 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..e9a5b3db96 --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/agent_adapter.rs @@ -0,0 +1,506 @@ +use crate::agentic::coordination::ConversationCoordinator; +use bitfun_events::{AgenticEvent, ToolEventData}; +use bitfun_product_domains::miniapp::loopx::{ + LoopxAgentCancelRequest, LoopxAgentCancelResult, LoopxAgentFinishRequest, + LoopxAgentFinishResult, LoopxAgentOutputSinceRequest, LoopxAgentOutputSinceResult, + LoopxAgentPort, LoopxAgentProbeRequest, LoopxAgentProbeResult, LoopxAgentResetRequest, + LoopxAgentResetResult, LoopxAgentStartRequest, LoopxAgentStartResult, LoopxHostFuture, + LoopxHostPortError, LoopxHostPortErrorKind, LoopxTurnOutputEvent, + LoopxTurnOutputEventKind, +}; +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 probe(&self, request: LoopxAgentProbeRequest) -> LoopxHostFuture<'_, LoopxAgentProbeResult> { + Box::pin(async move { + let config_service = crate::service::config::get_global_config_service() + .await + .map_err(|error| { + host_error( + LoopxHostPortErrorKind::Backend, + format!("LoopX Agent model configuration is unavailable: {error}"), + &request.operation_id, + ) + })?; + let global_config: crate::service::config::types::GlobalConfig = + config_service.get_config(None).await.map_err(|error| { + host_error( + LoopxHostPortErrorKind::Backend, + format!("LoopX Agent model configuration could not be read: {error}"), + &request.operation_id, + ) + })?; + let requested = request.model_id.as_deref().unwrap_or("auto").trim(); + let selector = if requested.is_empty() || matches!(requested, "auto" | "primary") { + "primary" + } else { + requested + }; + let model_id = global_config + .ai + .resolve_model_selection(selector) + .ok_or_else(|| { + host_error( + LoopxHostPortErrorKind::NotFound, + format!("LoopX Agent model '{selector}' is not configured or enabled"), + &request.operation_id, + ) + })?; + let model = global_config + .ai + .models + .iter() + .find(|model| model.id == model_id && model.enabled) + .ok_or_else(|| { + host_error( + LoopxHostPortErrorKind::NotFound, + format!("LoopX Agent model '{model_id}' is unavailable"), + &request.operation_id, + ) + })?; + if !model.capabilities.iter().any(|capability| { + matches!( + capability, + crate::service::config::types::ModelCapability::TextChat + ) + }) { + return Err(host_error( + LoopxHostPortErrorKind::Unsupported, + format!("LoopX Agent model '{model_id}' does not support text chat"), + &request.operation_id, + )); + } + let supports_images = model.capabilities.iter().any(|capability| { + matches!( + capability, + crate::service::config::types::ModelCapability::ImageUnderstanding + ) + }); + Ok(LoopxAgentProbeResult { + model_id, + supports_images, + }) + }) + } + + 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 reset(&self, request: LoopxAgentResetRequest) -> LoopxHostFuture<'_, LoopxAgentResetResult> { + Box::pin(async move { + let path_manager = + crate::infrastructure::try_get_path_manager_arc().map_err(|error| { + host_error( + LoopxHostPortErrorKind::Backend, + error.to_string(), + &request.operation_id, + ) + })?; + let root = crate::service::session_projection_store::runtime_event_log_dir( + path_manager.as_ref(), + ); + let mut entries = match tokio::fs::read_dir(&root).await { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(LoopxAgentResetResult::default()) + } + Err(error) => { + return Err(host_error( + LoopxHostPortErrorKind::Io, + format!("Failed to read LoopX runtime event directory: {error}"), + &request.operation_id, + )) + } + }; + let mut removed = 0_u32; + while let Some(entry) = entries.next_entry().await.map_err(|error| { + host_error( + LoopxHostPortErrorKind::Io, + format!("Failed to enumerate LoopX runtime event logs: {error}"), + &request.operation_id, + ) + })? { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if !name.starts_with("loopx-") || !name.ends_with(".jsonl") { + continue; + } + if !entry + .file_type() + .await + .map_err(|error| { + host_error( + LoopxHostPortErrorKind::Io, + format!("Failed to inspect LoopX runtime event log: {error}"), + &request.operation_id, + ) + })? + .is_file() + { + continue; + } + tokio::fs::remove_file(entry.path()) + .await + .map_err(|error| { + host_error( + LoopxHostPortErrorKind::Io, + format!("Failed to remove LoopX runtime event log: {error}"), + &request.operation_id, + ) + })?; + removed = removed.saturating_add(1); + } + Ok(LoopxAgentResetResult { + removed_runtime_event_logs: removed, + }) + }) + } + + fn output_since( + &self, + request: LoopxAgentOutputSinceRequest, + ) -> LoopxHostFuture<'_, LoopxAgentOutputSinceResult> { + Box::pin(async move { + let path_manager = + crate::infrastructure::try_get_path_manager_arc().map_err(|error| { + host_error( + LoopxHostPortErrorKind::Backend, + error.to_string(), + &request.operation_id, + ) + })?; + let root = crate::service::session_projection_store::runtime_event_log_dir( + path_manager.as_ref(), + ); + let page = crate::service::session_projection_store::read_runtime_events_since( + &root, + &request.session_id, + request.stream_id.as_deref(), + request.after_cursor, + request.limit, + ) + .map_err(|error| { + host_error( + LoopxHostPortErrorKind::Io, + error, + &request.operation_id, + ) + })?; + let Some(page) = page else { + return Ok(LoopxAgentOutputSinceResult { + next_cursor: request.after_cursor, + ..LoopxAgentOutputSinceResult::default() + }); + }; + let events = page + .events + .into_iter() + .filter_map(|record| turn_output_event(record.cursor, &request.turn_id, record.event)) + .collect(); + Ok(LoopxAgentOutputSinceResult { + stream_id: Some(page.stream_id), + events, + next_cursor: page.next_cursor, + has_more: page.has_more, + }) + }) + } +} + +fn turn_output_event( + cursor: u64, + expected_turn_id: &str, + event: AgenticEvent, +) -> Option { + if event.turn_id() != Some(expected_turn_id) { + return None; + } + match event { + AgenticEvent::TextChunk { + turn_id, + round_id, + text, + .. + } => Some(LoopxTurnOutputEvent { + cursor, + turn_id, + round_id: Some(round_id), + kind: LoopxTurnOutputEventKind::Text, + text: Some(text), + ..LoopxTurnOutputEvent::default() + }), + AgenticEvent::ThinkingChunk { + turn_id, + round_id, + content, + is_end, + .. + } => Some(LoopxTurnOutputEvent { + cursor, + turn_id, + round_id: Some(round_id), + kind: LoopxTurnOutputEventKind::Thinking, + text: Some(content), + is_end, + ..LoopxTurnOutputEvent::default() + }), + AgenticEvent::ModelRoundStarted { + turn_id, + round_id, + effective_model_name, + .. + } => Some(LoopxTurnOutputEvent { + cursor, + turn_id, + round_id: Some(round_id), + kind: LoopxTurnOutputEventKind::ModelRoundStarted, + text: Some(format!("Model round started: {effective_model_name}")), + ..LoopxTurnOutputEvent::default() + }), + AgenticEvent::ModelRoundCompleted { + turn_id, + round_id, + duration_ms, + .. + } => Some(LoopxTurnOutputEvent { + cursor, + turn_id, + round_id: Some(round_id), + kind: LoopxTurnOutputEventKind::ModelRoundCompleted, + text: Some(match duration_ms { + Some(value) => format!("Model round completed in {value} ms"), + None => "Model round completed".to_string(), + }), + ..LoopxTurnOutputEvent::default() + }), + AgenticEvent::ToolEvent { + turn_id, + round_id, + tool_event, + .. + } => Some(LoopxTurnOutputEvent { + cursor, + turn_id, + round_id: Some(round_id), + kind: LoopxTurnOutputEventKind::Tool, + text: tool_event_message(&tool_event), + tool_name: Some(tool_event.effective_tool_name().to_string()), + tool_state: Some(tool_event_state(&tool_event).to_string()), + ..LoopxTurnOutputEvent::default() + }), + _ => None, + } +} + +fn tool_event_state(event: &ToolEventData) -> &'static str { + match event { + ToolEventData::EarlyDetected { .. } => "detected", + ToolEventData::ParamsPartial { .. } => "params", + ToolEventData::Queued { .. } => "queued", + ToolEventData::Waiting { .. } => "waiting", + ToolEventData::Started { .. } => "started", + ToolEventData::Progress { .. } => "progress", + ToolEventData::Streaming { .. } => "streaming", + ToolEventData::StreamChunk { .. } => "stream", + ToolEventData::ConfirmationNeeded { .. } => "confirmation", + ToolEventData::Confirmed { .. } => "confirmed", + ToolEventData::Rejected { .. } => "rejected", + ToolEventData::Completed { .. } => "completed", + ToolEventData::Failed { .. } => "failed", + ToolEventData::Cancelled { .. } => "cancelled", + } +} + +fn tool_event_message(event: &ToolEventData) -> Option { + match event { + ToolEventData::Progress { + message, + percentage, + .. + } => Some(format!("{message} ({percentage:.0}%)")), + ToolEventData::Completed { duration_ms, .. } => { + Some(format!("Completed in {duration_ms} ms")) + } + ToolEventData::Failed { error, .. } => Some(error.clone()), + ToolEventData::Cancelled { reason, .. } => Some(reason.clone()), + ToolEventData::Queued { position, .. } => Some(format!("Queued at position {position}")), + ToolEventData::Waiting { dependencies, .. } if !dependencies.is_empty() => { + Some(format!("Waiting for {}", dependencies.join(", "))) + } + _ => None, + } +} + +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..392e06edd9 --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/controller.rs @@ -0,0 +1,2498 @@ +use super::{LoopxPersistedState, LoopxStateStore, LoopxTaskRuntimeRecord}; +use bitfun_product_domains::miniapp::loopx::*; +use std::collections::{BTreeMap, 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; +const INTAKE_PREVIEW_TTL_MS: i64 = 5 * 60 * 1000; +const MAX_INTAKE_PREVIEWS: usize = 64; + +struct ScheduledTask { + task_id: String, +} + +#[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_tasks: Mutex>, + 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_tasks: Mutex::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 { + let task_runner = Arc::clone(&task_runner); + tokio::spawn(async move { + if !task_runner.reserve_scheduled_task(&scheduled.task_id).await { + return; + } + loop { + let result = task_runner.drive_task(scheduled.task_id.clone()).await; + if let Err(error) = result { + let _ = task_runner.fail_task(&scheduled.task_id, error).await; + } + if !task_runner.release_scheduled_task(&scheduled.task_id).await { + break; + } + if !task_runner.reserve_scheduled_task(&scheduled.task_id).await { + break; + } + } + }); + } + }); + controller.enqueue_ready_tasks_after_load().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 turn_output_since( + &self, + request: LoopxTurnOutputSinceRequest, + ) -> LoopxTurnOutputSinceResponse { + let (task, runtime) = { + let state = self.state.read().await; + let Some(task) = state + .tasks + .iter() + .find(|task| task.task_id == request.task_id) + else { + return LoopxTurnOutputSinceResponse { + status: LoopxTurnOutputStatus::TaskNotFound, + task_id: request.task_id, + message: Some("LoopX task was not found".to_string()), + ..LoopxTurnOutputSinceResponse::default() + }; + }; + let runtime = state.runtime.get(&task.task_id).cloned().unwrap_or_default(); + (task.clone(), runtime) + }; + + if task.state != LoopxTaskState::Running || task.phase != LoopxPhase::AgentRunning { + return LoopxTurnOutputSinceResponse { + status: LoopxTurnOutputStatus::NotRunning, + task_id: task.task_id, + turn_id: task.current_turn_id, + message: Some("LoopX task does not have an active Agent turn".to_string()), + ..LoopxTurnOutputSinceResponse::default() + }; + } + let Some(session_id) = runtime.session_id else { + return LoopxTurnOutputSinceResponse { + status: LoopxTurnOutputStatus::OutputUnavailable, + task_id: task.task_id, + turn_id: task.current_turn_id, + message: Some("LoopX Agent session output is unavailable".to_string()), + ..LoopxTurnOutputSinceResponse::default() + }; + }; + let Some(turn_id) = runtime + .agent_turn_id + .clone() + .or_else(|| task.current_turn_id.clone()) + else { + return LoopxTurnOutputSinceResponse { + status: LoopxTurnOutputStatus::OutputUnavailable, + task_id: task.task_id, + message: Some("LoopX Agent turn output is unavailable".to_string()), + ..LoopxTurnOutputSinceResponse::default() + }; + }; + if request + .turn_id + .as_deref() + .is_some_and(|requested| requested != turn_id) + { + return LoopxTurnOutputSinceResponse { + status: LoopxTurnOutputStatus::StaleTurn, + task_id: task.task_id, + turn_id: Some(turn_id), + message: Some("LoopX task moved to a different Agent turn".to_string()), + ..LoopxTurnOutputSinceResponse::default() + }; + } + + match self + .agent + .output_since(LoopxAgentOutputSinceRequest { + operation_id: format!("output-agent-{}", uuid::Uuid::new_v4()), + session_id, + turn_id: turn_id.clone(), + stream_id: request.stream_id, + after_cursor: request.after_cursor, + limit: request.limit, + }) + .await + { + Ok(page) => LoopxTurnOutputSinceResponse { + status: LoopxTurnOutputStatus::Current, + task_id: task.task_id, + turn_id: Some(turn_id), + stream_id: page.stream_id, + events: page.events, + next_cursor: page.next_cursor, + has_more: page.has_more, + message: None, + }, + Err(error) => LoopxTurnOutputSinceResponse { + status: LoopxTurnOutputStatus::OutputUnavailable, + task_id: task.task_id, + turn_id: Some(turn_id), + message: Some(error.to_string()), + ..LoopxTurnOutputSinceResponse::default() + }, + } + } + + pub async fn refresh_environment(self: &Arc) -> Result<(), String> { + self.ensure_writable().await?; + let probe_id = uuid::Uuid::new_v4(); + self.mark_environment_checking().await?; + let progress = BufferedProgress::default(); + let handshake = self.cli.handshake( + LoopxCliHandshakeRequest { + call: LoopxCliCallContext { + operation_id: format!("environment-sidecar-{probe_id}"), + deadline_at: None, + }, + ..LoopxCliHandshakeRequest::default() + }, + &progress, + ); + let workspace = self.workspace.probe(LoopxWorkspaceProbeRequest { + operation_id: format!("environment-workspace-{probe_id}"), + repository: None, + }); + let agent = self.agent.probe(LoopxAgentProbeRequest { + operation_id: format!("environment-agent-{probe_id}"), + model_id: Some("auto".to_string()), + }); + let github_auth = self.probe_github_auth(); + let (handshake, workspace, agent, github_auth) = + tokio::join!(handshake, workspace, agent, github_auth); + self.record_progress(progress.take()).await?; + self.commit_environment(handshake, workspace, agent, github_auth) + .await + } + + 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 probe_id = uuid::Uuid::new_v4(); + let repository = target.repository().clone(); + let model_id = request.model_id; + let progress = BufferedProgress::default(); + let resolved = self.cli.resolve_intake( + LoopxCliResolveIntakeRequest { + call: LoopxCliCallContext { + operation_id: format!("resolve-metadata-{probe_id}"), + deadline_at: None, + }, + input: request.input, + target: target.clone(), + }, + &progress, + ); + let workspace_probe = self.workspace.probe(LoopxWorkspaceProbeRequest { + operation_id: format!("resolve-workspace-{probe_id}"), + repository: Some(repository), + }); + let agent_probe = self.agent.probe(LoopxAgentProbeRequest { + operation_id: format!("resolve-agent-{probe_id}"), + model_id: Some(model_id.clone()), + }); + let (resolved, workspace_probe, agent_probe) = + tokio::join!(resolved, workspace_probe, agent_probe); + self.record_progress(progress.take()).await?; + let resolved = resolved.map_err(|error| error.to_string())?; + let scopes = vec![ + LoopxPermissionScope::WorkspaceRead, + LoopxPermissionScope::WorkspaceWrite, + LoopxPermissionScope::GitLocal, + LoopxPermissionScope::GithubRead, + LoopxPermissionScope::AgentExecution, + ]; + let model = match agent_probe { + Ok(probe) => LoopxModelCapability { + model_id, + available: true, + supports_images: probe.supports_images, + detail: Some(format!("Resolved Agent model: {}", probe.model_id)), + }, + Err(error) => LoopxModelCapability { + model_id, + available: false, + supports_images: false, + detail: Some(error.to_string()), + }, + }; + let workspace = match workspace_probe { + Ok(probe) => LoopxWorkspacePreview { + disposition: LoopxWorkspaceDisposition::CloneRequired, + path: None, + repository_verified: probe.repository_verified, + detail: Some(format!( + "{}; repository access verified", + probe + .git_version + .unwrap_or_else(|| "Git available".to_string()) + )), + }, + Err(error) => LoopxWorkspacePreview { + disposition: LoopxWorkspaceDisposition::Unavailable, + path: None, + repository_verified: false, + detail: Some(error.to_string()), + }, + }; + let fingerprint = build_intake_fingerprint( + &resolved.target, + &resolved.candidates, + None, + &model.model_id, + &scopes, + ); + let preview_resolved_at = if resolved.resolved_at > 0 { + resolved.resolved_at + } else { + now_ms() + }; + let expires_at = preview_resolved_at.saturating_add(INTAKE_PREVIEW_TTL_MS); + 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: preview_resolved_at, + expires_at: Some(expires_at), + }; + let mut previews = self.previews.write().await; + prune_intake_previews(&mut previews, now_ms()); + previews.insert(fingerprint, preview.clone()); + prune_intake_previews(&mut previews, now_ms()); + 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 selected = request + .selected_items + .iter() + .cloned() + .collect::>(); + if selected.is_empty() { + return Err("Select at least one issue or pull request".to_string()); + } + { + let state = self.state.read().await; + if state.has_processed_request(&request.client_request_id) { + return Ok(LoopxCreateTaskResponse { + outcomes: existing_outcomes(&state, &selected), + snapshot_revision: state.revision, + }); + } + } + let preview = match { + let mut previews = self.previews.write().await; + prune_intake_previews(&mut previews, now_ms()); + previews.get(&request.preview_fingerprint).cloned() + } { + Some(preview) => preview, + None => { + let state = self.state.read().await; + if state.has_processed_request(&request.client_request_id) { + return Ok(LoopxCreateTaskResponse { + outcomes: existing_outcomes(&state, &selected), + snapshot_revision: state.revision, + }); + } + return Err("Intake preview is missing or stale; resolve it again".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 preview.workspace.disposition == LoopxWorkspaceDisposition::Unavailable + || !preview.workspace.repository_verified + { + return Err(preview.workspace.detail.clone().unwrap_or_else(|| { + "The repository workspace did not pass live Git verification".to_string() + })); + } + if !preview.model.available { + return Err(preview + .model + .detail + .clone() + .unwrap_or_else(|| "The selected Agent model is unavailable".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, + title: candidate.title.clone(), + description: candidate.description.clone(), + }, + 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() + }); + } + if request.action == LoopxActionKind::ResumeRepository { + return self.resume_repository(&request).await; + } + if request.action == LoopxActionKind::ResetAll { + return self.reset_all(&request).await; + } + 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 request.action == LoopxActionKind::Resume + && matches!( + task.state, + LoopxTaskState::Preparing + | LoopxTaskState::Queued + | LoopxTaskState::Running + | LoopxTaskState::RetryWait + ) + { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Duplicate, + current_revision: task.revision, + task: Some(task), + message: Some("Task is already queued or running".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::Abort => { + self.abort_task(&task, &runtime, &request.client_request_id) + .await + } + LoopxActionKind::Resume => self.resume_task(&task, &request.client_request_id).await, + LoopxActionKind::ResumeRepository | LoopxActionKind::ResetAll => unreachable!(), + LoopxActionKind::Approve | LoopxActionKind::Reject => { + self.answer_gate(&task, &runtime, &request).await + } + LoopxActionKind::Archive => { + let response = self + .transition_action( + &task_id, + LoopxTaskState::Archived, + LoopxPhase::Finished, + &request.client_request_id, + ) + .await?; + // Explicit user action: archive is the only workflow that + // destroys the task worktree (and its bare repository when + // the last worktree is gone). Terminal states keep their + // worktrees so the user can inspect agent output first. + self.dispose_task_workspace(&task).await; + Ok(response) + } + 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, + blocks_repository: bool, + ) -> 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.clone(), + }, + &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, + status, + summary.as_deref(), + blocks_repository, + ) + .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 !matches!( + task.state, + LoopxTaskState::Preparing | LoopxTaskState::Queued + ) { + return Ok(()); + } + 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?; + if task_has_bound_goal(&task) { + return self.drive_turn(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(), + title: task.identity.title.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 => { + let gate = inspected.pending_user_gate.ok_or_else(|| { + "LoopX requested a user decision without an answerable gate".to_string() + })?; + let LoopxCliUserGate { + gate_id, + message, + action_kind, + } = gate; + self.release_repository(&task).await; + let updated = self + .transition_task( + &task_id, + task.generation, + LoopxTaskState::WaitingForUser, + LoopxPhase::WaitingForApproval, + "LoopX requires an explicit user decision", + ) + .await?; + let mut details = BTreeMap::new(); + details.insert("gateId".to_string(), gate_id); + if let Some(action_kind) = action_kind { + details.insert("actionKind".to_string(), action_kind); + } + self.append_task_event_with_details( + &updated, + LoopxEventKind::ApprovalRequired, + &message, + true, + details, + ) + .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 + } + } + } + + /// Best-effort teardown of a task's agent run (cancel then finish). A stale + /// session — for example one persisted before a host restart — must not + /// abort pause or reset: the controller's persisted state is the task + /// lifecycle source of truth, so teardown failures are logged and the + /// operation continues. + async fn teardown_agent_run( + self: &Arc, + task: &LoopxTaskSnapshot, + runtime: &LoopxTaskRuntimeRecord, + ) { + let (Some(session_id), Some(turn_id)) = + (runtime.session_id.as_ref(), runtime.agent_turn_id.as_ref()) + else { + return; + }; + if let Err(error) = self + .agent + .cancel(LoopxAgentCancelRequest { + operation_id: format!("teardown-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 + { + log::warn!( + "LoopX agent cancel skipped for task {}: {}", + task.task_id, + error + ); + } + if let Err(error) = self + .agent + .finish(LoopxAgentFinishRequest { + operation_id: format!("teardown-agent-finish-{}", 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 + { + log::warn!( + "LoopX agent finish skipped for task {}: {}", + task.task_id, + error + ); + } + } + + 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?; + self.teardown_agent_run(task, runtime).await; + 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 abort_task( + self: &Arc, + task: &LoopxTaskSnapshot, + runtime: &LoopxTaskRuntimeRecord, + request_id: &str, + ) -> Result { + self.transition_task( + &task.task_id, + task.generation, + LoopxTaskState::Cancelling, + LoopxPhase::Cancelling, + "Aborting the active LoopX task", + ) + .await?; + self.teardown_agent_run(task, runtime).await; + let progress = BufferedProgress::default(); + let _ = self + .cli + .cancel( + LoopxCliCancelRequest { + call: LoopxCliCallContext { + operation_id: format!("abort-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::Aborted, + 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 resume_repository( + self: &Arc, + request: &LoopxActionRequest, + ) -> Result { + let repository = request + .repository + .as_ref() + .ok_or_else(|| "repository is required for resume_repository".to_string())?; + let repository_id = repository.canonical_id(); + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + if state.has_processed_request(&request.client_request_id) { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Duplicate, + current_revision: state.revision, + message: Some("Repository resume was already applied".to_string()), + ..LoopxActionResponse::default() + }); + } + if state.revision != request.expected_revision { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::RevisionConflict, + current_revision: state.revision, + message: Some( + "Task list changed; refresh before resuming the repository".to_string(), + ), + ..LoopxActionResponse::default() + }); + } + + let task_indexes = state + .tasks + .iter() + .enumerate() + .filter_map(|(index, task)| { + is_repository_recovery_candidate(task, &repository_id).then_some(index) + }) + .collect::>(); + let start_cursor = state.cursor; + let now = now_ms(); + let mut task_ids = Vec::with_capacity(task_indexes.len()); + for task_index in task_indexes { + let task_id = state.tasks[task_index].task_id.clone(); + let mut runtime = state.runtime.remove(&task_id).unwrap_or_default(); + { + let task = &mut state.tasks[task_index]; + 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; + task.updated_at = now; + 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; + } + let updated = state.tasks[task_index].clone(); + state.runtime.insert(task_id.clone(), runtime); + state.revision = state.revision.saturating_add(1); + state.append_event(LoopxEvent { + task_id: Some(task_id.clone()), + generation: Some(updated.generation), + revision: Some(updated.revision), + kind: LoopxEventKind::StateChanged, + source: LoopxEventSource::Controller, + phase: Some(LoopxPhase::Recovering), + message: "Task queued by repository resume".to_string(), + occurred_at: now, + ..LoopxEvent::default() + }); + task_ids.push(task_id); + } + state.record_processed_request(request.client_request_id.clone()); + let resumed_count = task_ids.len(); + let current_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 task_ids { + self.enqueue_task(task_id, Duration::ZERO)?; + } + Ok(LoopxActionResponse { + current_revision, + message: Some(format!("Queued {resumed_count} repository tasks")), + ..LoopxActionResponse::default() + }) + } + + async fn reset_all( + self: &Arc, + request: &LoopxActionRequest, + ) -> Result { + let (tasks, runtimes, previous_stream_id) = { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + if state.has_processed_request(&request.client_request_id) { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Duplicate, + current_revision: state.revision, + message: Some("LoopX reset was already applied".to_string()), + ..LoopxActionResponse::default() + }); + } + if state.revision != request.expected_revision { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::RevisionConflict, + current_revision: state.revision, + message: Some("LoopX state changed; refresh before resetting".to_string()), + ..LoopxActionResponse::default() + }); + } + let tasks = state.tasks.clone(); + let runtimes = state.runtime.clone(); + for task in &mut state.tasks { + if matches!( + task.state, + LoopxTaskState::Preparing + | LoopxTaskState::Queued + | LoopxTaskState::Running + | LoopxTaskState::RetryWait + | LoopxTaskState::Cancelling + ) { + task.state = LoopxTaskState::Cancelling; + task.phase = LoopxPhase::Cancelling; + task.revision = task.revision.saturating_add(1); + task.updated_at = now_ms(); + } + } + state.record_processed_request(request.client_request_id.clone()); + state.revision = state.revision.saturating_add(1); + let persisted = state.clone(); + let previous_stream_id = state.stream_id.clone(); + drop(state); + self.store.save(&persisted).await?; + (tasks, runtimes, previous_stream_id) + }; + + for task in &tasks { + let runtime = runtimes.get(&task.task_id).cloned().unwrap_or_default(); + self.teardown_agent_run(task, &runtime).await; + if !runtime.operation_id.trim().is_empty() { + let progress = BufferedProgress::default(); + let _ = self + .cli + .cancel( + LoopxCliCancelRequest { + call: LoopxCliCallContext { + operation_id: format!("reset-cli-{}", uuid::Uuid::new_v4()), + deadline_at: None, + }, + target_operation_id: runtime.operation_id.clone(), + }, + &progress, + ) + .await; + } + let _ = self + .workspace + .cancel(LoopxWorkspaceCancelRequest { + operation_id: format!("reset-workspace-{}", uuid::Uuid::new_v4()), + target_operation_id: format!("workspace-{}-{}", task.task_id, task.generation), + task_id: task.task_id.clone(), + }) + .await; + } + + self.workspace + .reset(LoopxWorkspaceResetRequest { + operation_id: format!("reset-workspaces-{}", uuid::Uuid::new_v4()), + }) + .await + .map_err(|error| error.to_string())?; + self.agent + .reset(LoopxAgentResetRequest { + operation_id: format!("reset-history-{}", uuid::Uuid::new_v4()), + }) + .await + .map_err(|error| error.to_string())?; + + let fresh = LoopxPersistedState::new(now_ms()); + self.store.clear().await?; + { + let _mutation = self.mutation_lock.lock().await; + *self.state.write().await = fresh.clone(); + self.active_tasks.lock().await.clear(); + self.active_repositories.lock().await.clear(); + self.previews.write().await.clear(); + } + let _ = self.event_sender.send(LoopxEvent { + stream_id: fresh.stream_id, + cursor: 0, + kind: LoopxEventKind::SnapshotInvalidated, + level: LoopxEventLevel::Info, + source: LoopxEventSource::Controller, + message: format!("LoopX reset replaced stream {previous_stream_id}"), + occurred_at: now_ms(), + ..LoopxEvent::default() + }); + // The fresh state starts with an unknown environment; validate it again + // so the UI can re-enable intake after "clear and start over". + self.refresh_environment().await?; + Ok(LoopxActionResponse { + current_revision: fresh.revision, + message: Some(format!( + "Cleared {} LoopX tasks, managed workspaces, and persisted controller state", + tasks.len() + )), + ..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, + agent_status: LoopxAgentTurnStatus, + failure_summary: Option<&str>, + blocks_repository: bool, + ) -> Result<(), String> { + let final_state = if agent_status == LoopxAgentTurnStatus::Failed { + LoopxTaskState::RecoveryRequired + } else { + 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 if final_state == LoopxTaskState::RecoveryRequired { + LoopxPhase::Recovering + } 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.error = (agent_status == LoopxAgentTurnStatus::Failed) + .then(|| failure_summary.unwrap_or("Agent turn failed").to_string()); + 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; + if agent_status == LoopxAgentTurnStatus::Failed { + let reason = failure_summary.unwrap_or("Agent turn failed"); + self.append_task_event(&updated, LoopxEventKind::StateChanged, reason, true) + .await?; + if blocks_repository { + self.pause_repository_after_failure(&updated, reason) + .await?; + } + } else { + let (kind, message, important) = match final_state { + LoopxTaskState::Completed => ( + LoopxEventKind::SettlementRecorded, + "LoopX goal completed", + false, + ), + LoopxTaskState::RecoveryRequired => ( + LoopxEventKind::StateChanged, + "LoopX turn requires recovery after settlement", + true, + ), + _ => ( + LoopxEventKind::SettlementRecorded, + "LoopX turn settlement recorded", + false, + ), + }; + self.append_task_event(&updated, kind, message, important) + .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 pause_repository_after_failure( + &self, + failed_task: &LoopxTaskSnapshot, + reason: &str, + ) -> Result<(), String> { + let repository_id = failed_task.identity.item.repository.canonical_id(); + let message = format!( + "Repository queue paused after Issue #{} failed: {}", + failed_task.identity.item.number, + reason.chars().take(700).collect::() + ); + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let start_cursor = state.cursor; + let now = now_ms(); + let mut paused = Vec::new(); + for task in &mut state.tasks { + if task.task_id == failed_task.task_id + || task.identity.item.repository.canonical_id() != repository_id + || task.state != LoopxTaskState::Queued + { + continue; + } + task.state = LoopxTaskState::RecoveryRequired; + task.phase = LoopxPhase::Recovering; + task.error = Some(message.clone()); + task.current_turn_id = None; + task.deadline_at = None; + task.revision = task.revision.saturating_add(1); + task.updated_at = now; + paused.push(task.clone()); + } + for task in &paused { + state.revision = state.revision.saturating_add(1); + state.append_event(LoopxEvent { + task_id: Some(task.task_id.clone()), + generation: Some(task.generation), + revision: Some(task.revision), + kind: LoopxEventKind::StateChanged, + level: LoopxEventLevel::Error, + source: LoopxEventSource::Controller, + phase: Some(LoopxPhase::Recovering), + message: message.clone(), + important: true, + occurred_at: now, + ..LoopxEvent::default() + }); + } + state.environment.core.agent_model.status = LoopxEnvironmentFactStatus::Degraded; + state.environment.core.agent_model.detail = Some(reason.to_string()); + state.environment.core.agent_model.checked_at = Some(now); + state.environment.status = + derive_environment_status(&state.environment.core, &state.environment.optional); + state.environment.revision = state.environment.revision.saturating_add(1); + state.revision = state.revision.saturating_add(1); + state.append_event(LoopxEvent { + kind: LoopxEventKind::EnvironmentChanged, + level: LoopxEventLevel::Error, + source: LoopxEventSource::System, + message: "Agent model runtime failed; repository queue paused".to_string(), + important: true, + occurred_at: now, + ..LoopxEvent::default() + }); + let persisted = state.clone(); + drop(state); + self.store.save(&persisted).await?; + drop(_mutation); + self.broadcast_new_events(&persisted, start_cursor); + 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 mark_environment_checking(&self) -> Result<(), String> { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let checked_at = Some(now_ms()); + state.environment.revision = state.environment.revision.saturating_add(1); + state.environment.status = LoopxEnvironmentStatus::Checking; + state.environment.checked_at = checked_at; + state.environment.core.sidecar = checking_environment_fact(checked_at); + state.environment.core.git_worktree = checking_environment_fact(checked_at); + state.environment.core.agent_model = checking_environment_fact(checked_at); + state.environment.optional.github_auth = checking_environment_fact(checked_at); + state.revision = state.revision.saturating_add(1); + state.append_event(LoopxEvent { + kind: LoopxEventKind::EnvironmentChanged, + source: LoopxEventSource::System, + message: "LoopX environment validation started".to_string(), + 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 commit_environment( + &self, + handshake: LoopxCliResult, + workspace: LoopxHostResult, + agent: LoopxHostResult, + github_auth: LoopxGithubAuthProbe, + ) -> Result<(), String> { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let checked_at = Some(now_ms()); + let sidecar = match handshake { + Ok(manifest) => LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Available, + version: Some(manifest.loopx_version), + detail: Some(manifest.executable.identity), + checked_at, + ..LoopxEnvironmentFact::default() + }, + Err(error) => unavailable_environment_fact(error.to_string(), checked_at), + }; + let git_worktree = match workspace { + Ok(probe) => LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Available, + version: probe.git_version, + detail: Some(format!("Writable workspace root: {}", probe.workspace_root)), + checked_at, + ..LoopxEnvironmentFact::default() + }, + Err(error) => unavailable_environment_fact(error.to_string(), checked_at), + }; + let agent_model = match agent { + Ok(probe) => LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Available, + version: Some(probe.model_id), + detail: Some("Configured Agent model is enabled for text chat".to_string()), + checked_at, + ..LoopxEnvironmentFact::default() + }, + Err(error) => unavailable_environment_fact(error.to_string(), checked_at), + }; + let github_auth = LoopxEnvironmentFact { + status: github_auth_fact_status(&github_auth), + detail: github_auth.detail, + checked_at, + ..LoopxEnvironmentFact::default() + }; + + state.environment.revision = state.environment.revision.saturating_add(1); + state.environment.checked_at = checked_at; + state.environment.core.sidecar = sidecar; + state.environment.core.git_worktree = git_worktree; + state.environment.core.agent_model = agent_model; + state.environment.optional.github_auth = github_auth; + state.environment.status = + derive_environment_status(&state.environment.core, &state.environment.optional); + let status = state.environment.status; + state.revision = state.revision.saturating_add(1); + state.append_event(LoopxEvent { + kind: LoopxEventKind::EnvironmentChanged, + level: if status == LoopxEnvironmentStatus::Blocked { + LoopxEventLevel::Error + } else { + LoopxEventLevel::Info + }, + source: LoopxEventSource::System, + message: format!("LoopX environment validation finished with status {status:?}"), + important: status == LoopxEnvironmentStatus::Blocked, + 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 { + if is_normal_process_lifecycle_message(&item.message) { + continue; + } + 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> { + let updated = 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?; + self.append_task_event( + &updated, + LoopxEventKind::StateChanged, + "Agent turn started", + false, + ) + .await + } + + async fn transition_task( + &self, + task_id: &str, + generation: u64, + state: LoopxTaskState, + phase: LoopxPhase, + message: &str, + ) -> Result { + 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?; + Ok(updated) + } + + 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, _| { + let workspace_was_never_prepared = task.workspace_path.is_none() + && task.goal_id.is_none() + && task.current_turn_id.is_none(); + task.state = if workspace_was_never_prepared { + LoopxTaskState::Failed + } else { + LoopxTaskState::RecoveryRequired + }; + task.phase = if workspace_was_never_prepared { + LoopxPhase::Finished + } else { + 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(()) + } + + /// Best-effort cleanup of the task's on-disk worktree. Called only from + /// the explicit Archive action; failure is recorded as an event, never + /// fatal to the transition. + async fn dispose_task_workspace(self: &Arc, task: &LoopxTaskSnapshot) { + if task.workspace_path.is_none() { + return; + } + let progress = BufferedProgress::default(); + let result = self + .workspace + .dispose(LoopxWorkspaceDisposeRequest { + operation_id: format!("dispose-{}", uuid::Uuid::new_v4()), + task_id: task.task_id.clone(), + item: task.identity.item.clone(), + }) + .await + .map_err(|error| error.message.clone()); + self.record_progress(progress.take()).await.ok(); + let important = result.is_err(); + let message = match result { + Ok(disposed) if disposed.removed => { + "Archived task worktree cleaned up (disk space released)".to_string() + } + Ok(_) => "Archived task had no managed worktree to clean up".to_string(), + Err(error) => { + // Keep the archive transition; surface cleanup failure. + format!("Failed to clean up archived task worktree: {error}") + } + }; + let _ = self + .append_task_event(task, LoopxEventKind::StateChanged, &message, important) + .await; + } + + 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); + } + } + + async fn enqueue_ready_tasks_after_load(&self) { + if self.load_error.read().await.is_some() { + return; + } + let task_ids = { + let state = self.state.read().await; + state + .tasks + .iter() + .filter(|task| task.state == LoopxTaskState::Queued) + .map(|task| task.task_id.clone()) + .collect::>() + }; + for task_id in task_ids { + let _ = self.enqueue_task(task_id, Duration::ZERO); + } + } + + fn enqueue_task(&self, task_id: String, delay: Duration) -> Result<(), String> { + if !delay.is_zero() { + let sender = self.task_sender.clone(); + tokio::spawn(async move { + tokio::time::sleep(delay).await; + let _ = sender.send(ScheduledTask { task_id }); + }); + return Ok(()); + } + self.task_sender + .send(ScheduledTask { task_id }) + .map_err(|_| "LoopX controller task runner is unavailable".to_string()) + } + + async fn reserve_scheduled_task(&self, task_id: &str) -> bool { + let mut active = self.active_tasks.lock().await; + match active.get_mut(task_id) { + Some(pending) => { + *pending = true; + false + } + None => { + active.insert(task_id.to_string(), false); + true + } + } + } + + async fn release_scheduled_task(&self, task_id: &str) -> bool { + self.active_tasks + .lock() + .await + .remove(task_id) + .unwrap_or(false) + } + + 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> { + self.append_task_event_with_details(task, kind, message, important, BTreeMap::new()) + .await + } + + async fn append_task_event_with_details( + &self, + task: &LoopxTaskSnapshot, + kind: LoopxEventKind, + message: &str, + important: bool, + details: BTreeMap, + ) -> 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 kind == LoopxEventKind::ApprovalRequired { + LoopxEventLevel::Warning + } else if important { + LoopxEventLevel::Error + } else { + LoopxEventLevel::Info + }, + source: LoopxEventSource::Controller, + phase: Some(task.phase), + message: message.to_string(), + important, + details, + 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 is_normal_process_lifecycle_message(message: &str) -> bool { + matches!( + message, + "Starting LoopX process" | "LoopX process exited successfully" + ) +} + +fn task_has_bound_goal(task: &LoopxTaskSnapshot) -> bool { + task.goal_id + .as_deref() + .is_some_and(|goal_id| !goal_id.trim().is_empty()) +} + +fn is_repository_recovery_candidate(task: &LoopxTaskSnapshot, repository_id: &str) -> bool { + task.identity.item.repository.canonical_id() == repository_id + && matches!( + task.state, + LoopxTaskState::Failed | LoopxTaskState::RecoveryRequired + ) +} + +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 prune_intake_previews(previews: &mut HashMap, now: i64) { + previews.retain(|_, preview| intake_preview_is_fresh(preview, now)); + if previews.len() <= MAX_INTAKE_PREVIEWS { + return; + } + + let mut by_age = previews + .iter() + .map(|(fingerprint, preview)| (fingerprint.clone(), preview.resolved_at)) + .collect::>(); + by_age.sort_by_key(|(_, resolved_at)| *resolved_at); + let excess = previews.len().saturating_sub(MAX_INTAKE_PREVIEWS); + for (fingerprint, _) in by_age.into_iter().take(excess) { + previews.remove(&fingerprint); + } +} + +fn intake_preview_is_fresh(preview: &LoopxIntakePreview, now: i64) -> bool { + match preview.expires_at { + Some(expires_at) => expires_at > now, + None => false, + } +} + +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 + } +} + +fn checking_environment_fact(checked_at: Option) -> LoopxEnvironmentFact { + LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Checking, + checked_at, + ..LoopxEnvironmentFact::default() + } +} + +fn unavailable_environment_fact( + detail: impl Into, + checked_at: Option, +) -> LoopxEnvironmentFact { + LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Unavailable, + detail: Some(detail.into()), + checked_at, + ..LoopxEnvironmentFact::default() + } +} + +#[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, + ..Default::default() + }; + assert_eq!(goal_id_for(&identity), "bfx-owner-repo-issue-42-2"); + } + + #[test] + fn repository_recovery_excludes_intentionally_stopped_tasks() { + let repository = LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "owner".to_string(), + repository: "repo".to_string(), + }; + let task = |state| LoopxTaskSnapshot { + identity: LoopxTaskIdentity { + item: LoopxIssueKey { + repository: repository.clone(), + kind: LoopxItemKind::Issue, + number: 42, + }, + ..LoopxTaskIdentity::default() + }, + state, + ..LoopxTaskSnapshot::default() + }; + let repository_id = repository.canonical_id(); + + assert!(is_repository_recovery_candidate( + &task(LoopxTaskState::RecoveryRequired), + &repository_id, + )); + assert!(is_repository_recovery_candidate( + &task(LoopxTaskState::Failed), + &repository_id, + )); + assert!(!is_repository_recovery_candidate( + &task(LoopxTaskState::Stopped), + &repository_id, + )); + } + + #[test] + fn successful_process_lifecycle_messages_are_not_persisted_as_task_events() { + assert!(is_normal_process_lifecycle_message( + "Starting LoopX process" + )); + assert!(is_normal_process_lifecycle_message( + "LoopX process exited successfully" + )); + assert!(!is_normal_process_lifecycle_message( + "LoopX process exited with an error" + )); + assert!(!is_normal_process_lifecycle_message( + "Building an idempotent external-host LoopX turn" + )); + } + + #[test] + fn resumed_tasks_with_a_goal_skip_intake_and_goal_creation() { + assert!(task_has_bound_goal(&LoopxTaskSnapshot { + goal_id: Some("goal-42".to_string()), + ..LoopxTaskSnapshot::default() + })); + assert!(!task_has_bound_goal(&LoopxTaskSnapshot::default())); + assert!(!task_has_bound_goal(&LoopxTaskSnapshot { + goal_id: Some(" ".to_string()), + ..LoopxTaskSnapshot::default() + })); + } +} 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..cdc4480431 --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/store.rs @@ -0,0 +1,320 @@ +use bitfun_product_domains::miniapp::loopx::{ + task_state_after_restart, LoopxEnvironmentSnapshot, LoopxEvent, LoopxEventKind, + LoopxEventLevel, LoopxEventSource, LoopxEventsPageStatus, LoopxEventsSinceResponse, + LoopxExecutionDomain, LoopxExecutionSupport, LoopxPhase, LoopxSnapshot, LoopxTaskSnapshot, + LoopxTaskState, +}; +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; + let mut recovery_required = 0usize; + let mut requeued = 0usize; + for task in &mut self.tasks { + let restarted = task_state_after_restart(task.state); + if restarted == task.state { + continue; + } + task.state = restarted; + task.phase = if restarted == LoopxTaskState::RecoveryRequired { + recovery_required = recovery_required.saturating_add(1); + LoopxPhase::Recovering + } else if restarted == LoopxTaskState::Queued { + requeued = requeued.saturating_add(1); + LoopxPhase::Queued + } else { + task.phase + }; + 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 { + let needs_recovery = recovery_required > 0; + let message = match (needs_recovery, requeued > 0) { + (true, true) => { + "Host restarted; interrupted LoopX tasks require recovery and pending tasks were requeued" + } + (true, false) => "Host restarted; interrupted LoopX tasks require explicit recovery", + (false, true) => "Host restarted; pending LoopX tasks were requeued", + (false, false) => "Host restarted; LoopX task state was refreshed", + }; + self.revision = self.revision.saturating_add(1); + self.append_event(LoopxEvent { + kind: LoopxEventKind::SnapshotInvalidated, + level: if needs_recovery { + LoopxEventLevel::Warning + } else { + LoopxEventLevel::Info + }, + source: LoopxEventSource::Controller, + phase: Some(if needs_recovery { + LoopxPhase::Recovering + } else { + LoopxPhase::Queued + }), + message: message.to_string(), + important: needs_recovery, + 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}")) + } + + pub async fn clear(&self) -> Result<(), String> { + match tokio::fs::remove_file(&self.path).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("Failed to remove LoopX task state: {error}")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_product_domains::miniapp::loopx::{LoopxTaskState, LoopxTaskSnapshot}; + + #[tokio::test] + async fn restart_requeues_pending_work_and_recovers_inflight_work() { + 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() + }); + state.tasks.push(LoopxTaskSnapshot { + task_id: "task-2".to_string(), + state: LoopxTaskState::RetryWait, + phase: LoopxPhase::RetryBackoff, + revision: 5, + created_at: 1, + updated_at: 2, + retry_at: Some(30), + ..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.tasks[1].state, LoopxTaskState::Queued); + assert_eq!(loaded.tasks[1].phase, LoopxPhase::Queued); + assert_eq!(loaded.tasks[1].retry_at, None); + assert_eq!(loaded.tasks[1].revision, 6); + 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..bfae497f76 --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/subscriber.rs @@ -0,0 +1,128 @@ +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 bitfun_core_types::errors::{AiErrorDetail, ErrorCategory}; +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, false) + .await + } + AgenticEvent::DialogTurnFailed { + turn_id, + error, + error_category, + error_detail, + .. + } => { + let summary = failure_summary(error, error_detail.as_ref()); + let blocks_repository = failure_blocks_repository( + error_category.as_ref(), + error_detail.as_ref(), + ); + self.controller + .handle_agent_terminal( + turn_id, + LoopxAgentTurnStatus::Failed, + Some(summary), + blocks_repository, + ) + .await + } + AgenticEvent::DialogTurnCancelled { turn_id, .. } => { + self.controller + .handle_agent_terminal( + turn_id, + LoopxAgentTurnStatus::Cancelled, + None, + false, + ) + .await + } + AgenticEvent::DialogTurnInterrupted { turn_id, .. } => { + self.controller + .handle_agent_terminal( + turn_id, + LoopxAgentTurnStatus::Interrupted, + None, + false, + ) + .await + } + _ => Ok(()), + }; + result.map_err(EventBusError::subscriber) + } +} + +fn failure_summary(error: &str, detail: Option<&AiErrorDetail>) -> String { + detail + .and_then(|detail| detail.provider_message.as_deref()) + .filter(|message| !message.trim().is_empty()) + .unwrap_or(error) + .chars() + .take(1_000) + .collect() +} + +fn failure_blocks_repository( + category: Option<&ErrorCategory>, + detail: Option<&AiErrorDetail>, +) -> bool { + let category = category.or_else(|| detail.map(|detail| &detail.category)); + matches!( + category, + Some( + ErrorCategory::Network + | ErrorCategory::Auth + | ErrorCategory::RateLimit + | ErrorCategory::Timeout + | ErrorCategory::ProviderQuota + | ErrorCategory::ProviderBilling + | ErrorCategory::ProviderUnavailable + | ErrorCategory::Permission + | ErrorCategory::InvalidRequest + | ErrorCategory::ModelError + ) + ) +} 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/assembly/core/src/service/session_projection_store.rs b/src/crates/assembly/core/src/service/session_projection_store.rs index dd5d664d38..16d95ea04b 100644 --- a/src/crates/assembly/core/src/service/session_projection_store.rs +++ b/src/crates/assembly/core/src/service/session_projection_store.rs @@ -18,6 +18,9 @@ use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; +const DEFAULT_RUNTIME_EVENT_PAGE_SIZE: usize = 200; +const MAX_RUNTIME_EVENT_PAGE_SIZE: usize = 1_000; + /// One appended line. `streamId` identifies the Runtime process that wrote it, /// so a log left by an older process is never mistaken for current progress. #[derive(serde::Serialize, serde::Deserialize)] @@ -28,6 +31,21 @@ struct LoggedEvent { event: AgenticEvent, } +#[derive(Debug, Clone)] +pub struct RuntimeEventRecord { + pub stream_id: String, + pub cursor: u64, + pub event: AgenticEvent, +} + +#[derive(Debug, Clone)] +pub struct RuntimeEventPage { + pub stream_id: String, + pub events: Vec, + pub next_cursor: u64, + pub has_more: bool, +} + /// Open append handles, keyed by Session. Holding the handle is what makes a /// per-event write an append to an already-open file rather than an open/close /// cycle per token. @@ -57,6 +75,94 @@ pub fn runtime_event_log_dir( path_manager.bitfun_home_dir().join("runtime-events") } +pub fn read_runtime_events_since( + root: &Path, + session_id: &str, + stream_id: Option<&str>, + after_cursor: u64, + requested_limit: Option, +) -> Result, String> { + let path = log_path(root, session_id); + let file = match File::open(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "Failed to open runtime event log for {session_id}: {error}" + )) + } + }; + + let limit = requested_limit + .map(|value| value as usize) + .unwrap_or(DEFAULT_RUNTIME_EVENT_PAGE_SIZE) + .clamp(1, MAX_RUNTIME_EVENT_PAGE_SIZE); + let mut latest_stream_id: Option = None; + let mut active_after_cursor = after_cursor; + let mut events = Vec::new(); + let mut has_more = false; + let mut next_cursor = after_cursor; + + for line in BufReader::new(file).lines() { + let line = line.map_err(|error| { + format!("Failed to read runtime event log for {session_id}: {error}") + })?; + if line.trim().is_empty() { + continue; + } + let record = match serde_json::from_str::(&line) { + Ok(record) => record, + Err(_) => break, + }; + match latest_stream_id.as_deref() { + Some(current) if current != record.stream_id => { + active_after_cursor = match stream_id { + Some(requested) if requested == record.stream_id => after_cursor, + Some(_) => 0, + None => after_cursor, + }; + events.clear(); + has_more = false; + next_cursor = active_after_cursor; + latest_stream_id = Some(record.stream_id.clone()); + } + None => { + active_after_cursor = match stream_id { + Some(requested) if requested == record.stream_id => after_cursor, + Some(_) => 0, + None => after_cursor, + }; + next_cursor = active_after_cursor; + latest_stream_id = Some(record.stream_id.clone()); + } + _ => {} + } + if record.cursor <= active_after_cursor { + continue; + } + if events.len() >= limit { + has_more = true; + continue; + } + next_cursor = record.cursor; + events.push(RuntimeEventRecord { + stream_id: record.stream_id, + cursor: record.cursor, + event: record.event, + }); + } + + let Some(latest_stream_id) = latest_stream_id else { + return Ok(None); + }; + Ok(Some(RuntimeEventPage { + stream_id: latest_stream_id, + events, + next_cursor, + has_more, + })) +} + impl FileSessionProjectionStore { pub fn new(root: PathBuf) -> Self { if let Err(error) = std::fs::create_dir_all(&root) { @@ -224,6 +330,25 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + #[test] + fn output_pages_reset_cursor_when_stream_changes() { + let root = temp_root(); + let store = FileSessionProjectionStore::new(root.clone()); + + store.append("session-1", "stream-old", 100, &text("session-1", "stale")); + store.append("session-1", "stream-new", 1, &text("session-1", "fresh")); + + let page = + read_runtime_events_since(&root, "session-1", Some("stream-old"), 100, Some(10)) + .expect("page read succeeds") + .expect("latest stream exists"); + assert_eq!(page.stream_id, "stream-new"); + assert_eq!(page.events.len(), 1); + assert_eq!(page.events[0].cursor, 1); + assert_eq!(page.next_cursor, 1); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn a_torn_final_line_keeps_everything_already_durable() { let root = temp_root(); 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..1329938738 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,22 @@ 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 || {{}}), + turnOutputSince: (opts) => _rpc('loopx.turnOutputSince', 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/AGENTS.md b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/AGENTS.md new file mode 100644 index 0000000000..39ceabcfe1 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/AGENTS.md @@ -0,0 +1,110 @@ +# AGENTS.md — bitfun-loopx 内置 MiniApp 开发协定 + +本文件仅适用于本目录(bitfun-loopx 内置 MiniApp 的**唯一权威源码**),比仓库根 +`AGENTS.md`、`src/crates/contracts/AGENTS.md` 更具体,冲突时以本文件为准。开发 +流程细节见本目录 `README.md`「高效修改流程」,这里只钉住**用户明确要求的迭代原则**。 + +## 最高优先级:快速反馈迭代(用户要求,不可违背) + +1. **不要主动编译**:只有用户明确要求 agent 编译时,才进行构建/编译。被要求编译时, + 构建/编译一律放后台并行执行(background job),不要在等待期间空转;优先完成不依赖 + 编译结果的独立工作。 +2. **修改代码之后不运行测试用例**:默认不跑 `cargo test`、`pnpm test`、 + thin-client / 契约测试等任何测试。只有用户**明确要求**时才运行。 + - 允许的秒级自检:`node --check ui.js` / `node --check worker.js`、 + 肉眼确认 JSON 合法、`git diff --check`。这些不是测试。 +3. **改完尽快交付可见结果**:每次修改后,以最快路径让用户看到效果并等待反馈; + 但只有用户明确要求编译时,才执行以下编译与重启步骤: + - 纯 UI(`index.html` / `style.css` / `ui.js`):批量做完一轮修改 → + `node --check` → 用户要求编译后再单次重新编译 Desktop 二进制 → 重启应用; + - Rust 宿主行为:完成一批修改 → 用户要求编译后再执行最窄 `cargo check` → 单次 + `cargo build -p bitfun-desktop`(统一配方,见 README「统一构建配方」,勿混用 + 不同 profile 环境变量)→ 重启应用。 +4. **先收集反馈,再继续下一轮**:交付可见结果后停下,等用户反馈;不要自行 + 连锁扩展改动范围("快速迭代"≠"一次改很多")。 + +## 分层最小动作(速查) + +| 修改文件 | 用户要求编译前可做的最小动作 | 用户明确要求编译后(何时才编译 Desktop) | +|---|---|---| +| `index.html` / `style.css` / `ui.js` | 连续批量编辑;`node --check ui.js` | 单次重新编译 Desktop 二进制,然后重启应用 | +| `worker.js` | `node --check worker.js` | 重新编译并重启 Worker | +| `meta.json` / `esm_dependencies.json` | 检查 JSON 与权限差异 | 编译并 reseed | +| `src/crates/contracts/product-domains/src/miniapp/loopx/**` | 完成一轮修改,等待用户要求 | 先最窄 `cargo check -p bitfun-product-domains --no-default-features --features miniapp`,再按需联调前编译 | +| `src/crates/services/services-integrations/src/miniapp/loopx_*.rs` | 完成一轮修改,等待用户要求 | 先最窄 `cargo check -p bitfun-services-integrations --no-default-features --features miniapp-loopx`,再按需联调前编译 | +| `src/crates/assembly/core/src/miniapp/loopx/**` | 完成一轮修改,等待用户要求 | 先最窄 `cargo check -p bitfun-core --no-default-features --features tools-miniapp`,再按需联调前编译 | +| `scripts/build-loopx.mjs` / LoopX pin | 不做动作,等待用户要求 | `pnpm run build:loopx`(只在用户要求且 sidecar/pin 变化时) | + +> 编译总原则:上表只是「用户明确要求编译时的最小动作」,不代表 agent 可以自行触发编译; +> 默认只有用户明确要求 agent 编译时才编译。 + +注:宿主目录见 `src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx` 的 +上一级(`../../../../../..` 之外的 Rust 目录),完整说明在 `README.md`。 + +## 快速循环约定 + +- 通常保持 Web UI Vite 常驻(`pnpm --dir src/web-ui dev`,端口 1422),Frontend + 改动走 HMR;MiniApp 资源因 `include_str!` 内嵌,仍需编译才能进二进制。仅在下方 + Windows 低内存规则触发时临时暂停,构建结束后必须恢复。 +- 2026-08-26 失败复盘:只重新编译并启动 `target/debug/bitfun-desktop.exe`,但没有确认 + Web UI Vite 已恢复,会让 Desktop WebView 打开 `localhost` 后显示 + `ERR_CONNECTION_REFUSED`。启动或重启 Desktop 前必须确认 1422 已监听,并做一次 HTTP + 探测;若未监听,先后台启动 `pnpm --dir src/web-ui dev --host 127.0.0.1`,确认 + `http://127.0.0.1:1422/` 返回 200 后再启动 Desktop。 +- 每次重新编译前**先停掉正在运行的 `bitfun-desktop.exe`**,避免两个实例抢占 + 同一个 AppData / reseed 目录。 +- 启动直接用刚编译的 `target/debug/bitfun-desktop.exe`(Vite 保持运行), + **不要**用 `pnpm run desktop:dev` 反复启停做 UI 微调。 +- 编译、启动一律放后台 job;新二进制会按内容哈希自动 reseed `compiled.html`。 + +## Windows Desktop 编译防重跑规范(2026-08-26 失败复盘) + +本机为 16 GB Windows。一次失败流程中,已有的不同 profile `cargo check` 长时间占锁, +随后另一条 `cargo test` 抢占同一 target;默认并发的正式 build 又多次在没有 Rust 诊断时 +退出。实测单个 `bitfun-core` `rustc` 工作集接近 5 GB。为避免等待、冷重编和内存峰值, +用户明确要求编译时必须遵守: + +1. **编译前双重验锁**:先查看所有 `cargo` / `rustc` 的 PID、命令行和开始时间;已有 + Cargo 时不得再启动 check、test 或 build,也不得终止不属于当前任务的进程。等待其 + 结束,并在正式 build 前立即复查一次,确认 target 无竞争者。 +2. **所有 Cargo 命令共用同一 profile**:用户明确要求 Rust 检查和 Desktop 编译时, + 最窄 `cargo check` 与最终 `cargo build` 必须在同一 shell 中继承 README 的三个 + `CARGO_PROFILE_DEV_*` 值。禁止先跑裸 `cargo check` 再切到统一配方 build;这会让 + Cargo 指纹变化并重编大依赖树。 +3. **本机强制单并发**:在统一 profile 之外设置 `CARGO_BUILD_JOBS=1`。该变量只限制 + 同时运行的 rustc 数量,不改变 Cargo 指纹;不要用默认并发或 `-j 2` 反复碰内存 + 上限。统一命令为: + + ```powershell + $env:CARGO_PROFILE_DEV_DEBUG = "0" + $env:CARGO_PROFILE_DEV_INCREMENTAL = "true" + $env:CARGO_PROFILE_DEV_CODEGEN_UNITS = "256" + $env:CARGO_BUILD_JOBS = "1" + cargo build -p bitfun-desktop + ``` + + 构建前同时检查系统可用提交空间;低于 2 GB,或单个 rustc 运行期间降到 1 GB 左右时, + 可以临时停止**仅属于本仓库**的 Vite 进程链,构建结束后用原命令恢复。2026-08-26 + 实测暂停 Vite 将可用提交空间从约 650 MB 恢复到约 1.8 GB,使最终链接完成。不得为 + 编译关闭其他用户应用、Codex 进程或无关服务。 + +4. **只保留一个可追踪的后台 build**:记录后台 job/session、构建开始时间和输出日志, + 持续轮询同一个句柄直到退出;不得因为暂时没有输出而重复启动。正常构建不要用 + `-vv`,只有无诊断退出时才用它定位最后一个 rustc 命令。 +5. **成功必须有四项证据**:Cargo 退出码为 0;输出含 `Finished`; + `target/debug/bitfun-desktop.exe` 的 `LastWriteTime` 晚于本次构建开始;Cargo/rustc + 已全部退出。缺一项都视为构建失败,不得启动旧 exe 冒充新版本。 +6. **启动前后都核对进程**:build 前停止全部 `bitfun-desktop.exe` 并确认已经退出, + 防止最终链接或 AppData reseed 冲突;仅在上述成功证据齐全后后台启动新 exe,再核对 + 进程 `StartTime` 与路径。构建期间若 Desktop 被其他入口重新拉起,先停掉它再等待链接。 +7. **失败先诊断,不盲目重跑**:先检查日志尾部、竞争 Cargo 命令、exe 时间戳和系统 + 可用内存。无 `error:` / 无 `Finished` 且 exe 时间戳未变,说明没有产出新应用;不要 + 宣称编译成功,也不要继续运行旧二进制。测试仍只在用户明确要求时运行。 + +## 禁止事项 + +- 不要主动编译:用户没有明确要求时,不执行 `cargo check`、`cargo build`、 + `pnpm run build:loopx` 等任何构建/编译动作。 +- 不要在每次代码改动后主动跑测试套件(见上)。 +- 不要直接修改 `%APPDATA%/bitfun/data/miniapps/builtin-bitfun-loopx/**` 当源码。 +- 不要用 `git add .` 或把运行目录/生成物(`compiled.html`、`~/.bitfun/bitfun-loopx/**`)提交。 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..2d18815aa1 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/README.md @@ -0,0 +1,264 @@ +# 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` 的权限与注册条目保持一致。 + +## 高效修改流程 + +### 先判断改动属于哪一层 + +| 修改文件 | 改动类型 | 开发期最小动作 | 何时需要 Desktop 编译 | +|---|---|---|---| +| `index.html` / `style.css` | MiniApp 结构、布局、样式 | 集中完成一批修改,不跑 Rust 检查 | 真机验证前编译一次 | +| `ui.js` | MiniApp 交互和宿主桥调用 | `node --check <本目录>/ui.js` | 真机验证前编译一次 | +| `worker.js` | MiniApp Worker、LoopX CLI 调用 | `node --check <本目录>/worker.js` | 真机验证前编译一次并重启 Worker | +| `meta.json` | 权限、版本、运行方式 | 检查 JSON 和权限差异 | 必须编译并 reseed | +| `esm_dependencies.json` | 浏览器 ESM 依赖 | 检查 JSON;确认 import map | 必须编译并 reseed | +| `src/crates/contracts/product-domains/src/miniapp/loopx/**` | LoopX DTO、状态、端口、纯策略 | `cargo check -p bitfun-product-domains --no-default-features --features miniapp` | 联调前编译一次 | +| `src/crates/services/services-integrations/src/miniapp/loopx_*.rs` | GitHub、Git workspace、CLI 等宿主服务 | `cargo check -p bitfun-services-integrations --no-default-features --features miniapp-loopx` | 联调前编译一次 | +| `src/crates/assembly/core/src/miniapp/loopx/**` | controller、Agent 编排、持久状态 | `cargo check -p bitfun-core --no-default-features --features tools-miniapp` | 联调前编译一次 | +| `scripts/build-loopx.mjs` 或 LoopX pin | 随包 sidecar | `pnpm run build:loopx` | 只在 sidecar/pin 变化时 | + +这里的 `<本目录>` 是: + +```text +src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx +``` + +### 纯 UI 快速循环 + +内置 MiniApp 资源由 `include_str!` 嵌入 Desktop 二进制。当前没有一个既能热替换 +source、又能继续通过受信任 built-in 校验的免编译入口。因此正确的快速循环是 +“多次编辑,一次编译”,而不是每保存一次就启动 Desktop 构建。 + +1. 保持 Web UI Vite 常驻;没有运行时才启动: + + ```bash + pnpm --dir src/web-ui dev + ``` + +2. 连续修改 `index.html`、`style.css`、`ui.js`,先把一轮视觉交互做完整。 +3. JS 变化只做秒级语法检查: + + ```bash + node --check src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/ui.js + ``` + +4. 需要在真实 MiniApp bridge 中验收时,停止正在运行的 `bitfun-desktop`,只构建 + 一次 Desktop executable(配方见下方「统一构建配方」,不要换用别的环境变量): + + ```bash + cargo build -p bitfun-desktop + ``` + +5. 保持 Vite 不退出,直接启动刚生成的 executable: + + ```text + target/debug/bitfun-desktop.exe + ``` + +6. 新二进制启动后会根据 built-in 内容哈希自动 reseed,并重新生成 + `compiled.html`。此时再进入 LoopX MiniApp 验收。 + +快速循环中不要使用 `pnpm run desktop:dev` 反复启停。该入口会执行资源准备、 +Cargo watch 和 target GC;在 Windows 上可能导致本来可增量的 UI 修改退化为大范围 +冷编译。它适合需要持续修改 Desktop Rust 的完整开发会话,不适合只调 MiniApp CSS。 + +### 为什么不能直接改 AppData + +不要把下面的运行目录当成源码: + +```text +%APPDATA%/bitfun/data/miniapps/builtin-bitfun-loopx/source/** +%APPDATA%/bitfun/data/miniapps/builtin-bitfun-loopx/compiled.html +``` + +- `compiled.html` 是生成物,刷新、recompile 或重启后会被覆盖; +- `source/**` 必须与二进制内嵌的 built-in source 一致;直接修改会使 + `builtin_source_matches` 失败,受信任的 LoopX controller bridge 将被禁用; +- `miniapp_sync_from_fs` 适用于普通 MiniApp 或明确的本地定制流程,不能作为受信任 + built-in LoopX 的临时热补丁; +- 只改运行目录会造成“代码已经改了,但界面仍旧”或“界面变了,但 bridge 不可用”。 + +唯一权威源码始终是本 README 所在目录。 + +### 宿主行为修改 + +GitHub 认证/限流、Git workspace、环境预检、Agent 会话、任务恢复和持久状态不是 +纯 MiniApp UI。这些改动需要修改对应 Rust owner,并先运行上表中的一个最窄 +`cargo check`。不要同时运行多个 Cargo 命令,也不要在 `desktop:dev` 正在自动编译时 +再手动启动 Cargo;它们会争用同一个 target 锁。 + +完成所有宿主改动后再执行一次: + +```bash +cargo build -p bitfun-desktop +``` + +### 统一构建配方(重要) + +`bitfun-desktop` 的默认 features 为空,`--no-default-features` 没有实际差异;真正 +影响全量/增量的是 **profile 环境变量**。Cargo 指纹包含 `CARGO_PROFILE_DEV_*` 设置, +一旦与上一次构建不同,整棵依赖树都会重编。请始终使用与 +`scripts/dev.cjs`(`desktop-preview` 快速重建)**完全相同**的配方: + +```powershell +$env:CARGO_PROFILE_DEV_DEBUG = "0" +$env:CARGO_PROFILE_DEV_INCREMENTAL = "true" +$env:CARGO_PROFILE_DEV_CODEGEN_UNITS = "256" +cargo build -p bitfun-desktop +``` + +或单行(cmd): + +```bat +set CARGO_PROFILE_DEV_DEBUG=0&& set CARGO_PROFILE_DEV_INCREMENTAL=true&& set CARGO_PROFILE_DEV_CODEGEN_UNITS=256&& cargo build -p bitfun-desktop +``` + +- **不要**混用不同的 `CARGO_PROFILE_DEV_*` / `CODEGEN_UNITS` 配置,也不要与 + `--no-default-features` 的裸命令交替使用——那会把本可增量的重编退化成全量冷编译; +- 需要断点调试信息时(`CARGO_PROFILE_DEV_DEBUG=2`)、或需要同时持续改 Desktop + Rust 时,改用 `pnpm run desktop:dev` 完整会话,不要在同一 target 上混跑; +- 装 sccache 可进一步让配方/feature 切换也不触发全量重编(可选优化)。 + +### 提交或发布前 + +开发期不需要 bump version,内容哈希变化会触发 reseed。发布时才做以下动作: + +1. 同步增加 `builtin.rs` 中 `BUILTIN_APPS` 的 version 和 `meta.json` 的 version; +2. 运行聚焦 built-in 契约测试: + + ```bash + cargo test -p bitfun-product-domains --features product-full builtin_miniapp + ``` + +3. sidecar pin 或构建脚本有变化时运行: + + ```bash + pnpm run build:loopx + ``` + +4. 需要完整 Desktop 构建产物时使用仓库入口: + + ```bash + pnpm run desktop:build:fast + ``` + +规范依据:`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 等)。 +- **目标模型:一个 goal 只对应一个 issue/PR**(`goal_id_for` 生成 + `bfx-owner-repo-issue-N`,重试追加 `-attempt` 后缀),每个 item 有独立 + worktree 与 `.loopx/registry.json`;todo 是 **goal 内部** 的推进项/审批门禁 + (`todo add --goal-id` 强绑定单一 goal),不用一串 todo 把多个 issue 串在 + 一个 goal 下。依据:loopx v0.5.x 的 goal 是「单一 objective 的持续 turn 载体」, + quota/心跳/审批/结算都以 goal 为域,registry 本身支持多 goal 列表—— + 多 issue 的"批量管理"由本应用 task/batch 层聚合,不压平到 loopx goal。 +- **心跳调度**:本应用维护一个统一的 task 调度循环(非每 issue 一个独立 + 定时器);每轮 inspect_goal 由 loopx 返回 `scheduler_hint_ms`(含指数退避), + 宿主据此把该 goal 重新排队;同一仓库的多个 goal 串行推进( + `active_repositories` + `schedule_next_for_repository`)。 +- **worktree 成本**:同仓库所有 task 共享一份裸仓库对象库 + (`//bare.git/`,首个 task `git clone --bare` 建立),每个 + task 用 `git worktree add -b bitfun-loopx/` 挂出独立工作区 + (`///`)。磁盘 ≈ 1 份对象库 + 各 task 的检出 + 文件;历史版本升级前创建的旧式独立克隆(每 task 一份完整 `.git`)仍可 + 正常复用,不强制迁移。空间不足时**归档(Archive)是唯一释放磁盘的入口**: + dispose 先 `git worktree remove --force` 删除该 task 工作区,再按 + `git worktree list` 剩余条目数判断是否删除共享裸仓库(最后一个 worktree + 离开时整仓回收)。loopx 上游只 `connect` 已存在项目、不 clone,克隆策略 + 是宿主侧职责,改动需同步 `loopx_workspace.rs` 与克隆契约测试。 +- v1 不设 turn 预算/成本上限:心跳节奏由 loopx 配额控制,宿主 Agent 消耗的 + 模型额度由用户在 BitFun 侧自行管理。 + +## loopx 依赖与合规 + +- **内置编译二进制(随安装包分发)**:打包流程(`scripts/desktop-tauri-build.mjs`, + 即 `pnpm run desktop:build*` 的 bundle 路径)会先执行 `scripts/build-loopx.mjs`: + 构建期拉取 pin `v0.5.1` 的 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.5.1`),升级必须伴随 + 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..2eaf6b2d0f --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/index.html @@ -0,0 +1,324 @@ + + + + + + LoopX + + + + +
+
+
+ +
+ LoopX + 正在连接宿主 +
+
+ +
+ + + + + 模型 + + + +
+ +
+ + +
+
+ + + + +
+ + + + 环境 + -- + + + +
+
+
+

核心环境

+ 必需 +
+
+
+
+
+

增强能力

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

运行日志

+ +
+
+

所有任务的实时日志和阶段进度

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

暂无运行事件

+
+
    +
    + + +
    +
    +
    + + +
    +
    +
    + 确认任务 +

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

    Issue / PR

    + 0 + +
    +
    +
    +
    +
    +

    本次权限

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

    已有终态任务

    +
    +
    +
    +

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

    +
    +
    +
    +

    + +
    +
    + +
    + + +
    +
    +
    +
    + + +
    +
    +
    + 批量操作 +

    继续此仓库的任务

    +
    +
    +
    +

    +
    +
    + + +
    +
    +
    + + +
    +
    +
    + 危险操作 +

    清空并重新开始

    +
    +
    +
    +

    +

    模型配置、GitHub 登录和 MiniApp 设置会保留。

    +
    +
    + + +
    +
    +
    + + + + 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..81ecfc7666 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/style.css @@ -0,0 +1,890 @@ +*, *::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) auto; + 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); +} +.header-actions { display: flex; align-items: center; gap: 5px; } + +.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; + gap: 6px; + min-width: 0; + height: 28px; + padding: 0 6px 0 8px; + border: 1px solid var(--lx-border); + border-radius: 6px; + background: var(--lx-element); + color: var(--lx-text-soft); + font-size: 11px; + cursor: pointer; +} +.model-field:hover, .model-field:focus-within { + border-color: color-mix(in srgb, var(--lx-accent) 56%, var(--lx-border)); + background: var(--lx-elevated); +} +.model-field__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.model-field__select { + min-width: 0; + width: clamp(96px, 18vw, 220px); + border: 0; + background: transparent; + color: var(--lx-text); + font: inherit; + font-size: 11px; + cursor: pointer; + outline: none; +} +.model-field__select[data-loading="true"] { color: var(--lx-text-muted); } + +.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; } +.candidate-select-all { display: inline-flex; align-items: center; gap: 4px; color: var(--lx-text-muted); font-size: 10px; cursor: pointer; } +.candidate-select-all input { margin: 0; } +.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: var(--rail-width, 286px) 5px minmax(0, 1fr); flex: 1 1 auto; min-height: 0; } +.workbench.tasks-collapsed { grid-template-columns: 52px minmax(0, 1fr); } +.workbench.tasks-collapsed .rail-splitter { display: none; } +.rail-splitter { + cursor: col-resize; + background: transparent; + touch-action: none; +} +.rail-splitter::after { + content: ''; + display: block; + width: 100%; + height: 100%; + background: transparent; + transition: background 0.12s ease; +} +.rail-splitter:hover::after, .rail-splitter.is-dragging::after { background: var(--lx-accent, var(--lx-border-strong, #888)); } +.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; } +.repository-actions { + display: grid; + gap: 3px; + padding: 8px; + border-bottom: 1px solid var(--lx-border); + background: var(--lx-muted-surface); +} +.repository-actions button { width: 100%; min-height: 32px; font-size: 11px; font-weight: 600; } +.repository-actions span { color: var(--lx-text-muted); font-size: 9.5px; text-align: center; } +.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; +} +.count-badge--attention { background: color-mix(in srgb, var(--lx-warning) 16%, transparent); color: var(--lx-warning); } +.task-list { + flex: 1 1 0; + min-height: 0; + padding: 6px; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} +#task-items { min-height: 0; } +.task-group { display: block; margin: 0 0 8px; } +.task-group__heading, .task-group > summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 26px; + padding: 4px 7px; + color: var(--lx-text-muted); + font-size: 9.5px; + font-weight: 600; + list-style: none; +} +.task-group > summary { + border: 1px solid transparent; + border-radius: 7px; + cursor: pointer; + user-select: none; +} +.task-group > summary:hover { + border-color: var(--lx-border); + background: var(--lx-hover); + color: var(--lx-text-soft); +} +.task-group > summary:focus-visible { + outline: 2px solid var(--lx-accent); + outline-offset: 1px; +} +.task-group > summary::-webkit-details-marker { display: none; } +.task-group__label, .task-group__meta { display: inline-flex; align-items: center; min-width: 0; } +.task-group__label { gap: 6px; } +.task-group__label-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.task-group__meta { flex: 0 0 auto; gap: 6px; } +.task-group__chevron { + position: relative; + display: inline-grid; + place-items: center; + width: 15px; + height: 15px; + border: 1px solid var(--lx-border); + border-radius: 5px; + background: var(--lx-element); + color: var(--lx-text-muted); +} +.task-group__chevron::before { + content: ""; + width: 5px; + height: 5px; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(-45deg); + transition: transform .14s ease; +} +.task-group[open] > summary .task-group__chevron::before { transform: rotate(45deg); } +.task-group__count { + min-width: 18px; + padding: 0 5px; + border-radius: 7px; + background: var(--lx-element); + font: 9px/16px var(--lx-mono); + text-align: center; +} +.task-group__toggle { font-size: 9px; } +.task-group[data-group="decision"] > .task-group__heading { color: var(--lx-warning); } +.task-group[data-group="error"] > .task-group__heading { color: var(--lx-error); } +.task-group[data-group="active"] > .task-group__heading { color: var(--lx-info); } +.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[data-state="running"] { + position: relative; + overflow: hidden; + border-color: color-mix(in srgb, var(--lx-info) 42%, var(--lx-border)); + background: color-mix(in srgb, var(--lx-info) 10%, var(--lx-element)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--lx-info) 16%, transparent); +} +.task-item[data-state="running"]::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: var(--lx-info); +} +.task-item[data-state="running"]::after { + content: ""; + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--lx-info), transparent); + animation: running-sweep 1.7s linear infinite; +} +.task-item[data-state="running"].is-selected { + border-color: color-mix(in srgb, var(--lx-info) 58%, var(--lx-border)); + background: color-mix(in srgb, var(--lx-info) 14%, var(--lx-elevated)); +} +.task-item[data-state="cancelling"], .task-item[data-pending] { + position: relative; + overflow: hidden; + border-color: color-mix(in srgb, var(--lx-warning) 48%, var(--lx-border)); + background: color-mix(in srgb, var(--lx-warning) 12%, var(--lx-element)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--lx-warning) 14%, transparent); +} +.task-item[data-state="cancelling"]::before, .task-item[data-pending]::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: var(--lx-warning); +} +.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__icon[data-status="running"] { + background: var(--lx-info); + color: white; + animation: pulse-status 1.4s ease-in-out infinite; +} +.task-item__icon[data-status="preparing"], .task-item__icon[data-status="queued"], .task-item__icon[data-status="retry_wait"] { color: var(--lx-info); } +.task-item__icon[data-status="waiting_for_user"] { background: color-mix(in srgb, var(--lx-warning) 14%, transparent); color: var(--lx-warning); } +.task-item__icon[data-status="recovery_required"] { background: color-mix(in srgb, var(--lx-warning) 12%, transparent); color: var(--lx-warning); } +.task-item__icon[data-status="failed"] { background: color-mix(in srgb, var(--lx-error) 12%, transparent); color: var(--lx-error); } +.task-item__icon[data-status="completed"] { background: color-mix(in srgb, var(--lx-success) 12%, transparent); color: var(--lx-success); } +@keyframes pulse-status { 50% { opacity: .55; } } +@keyframes running-sweep { + from { transform: translateX(-100%); } + to { transform: translateX(100%); } +} +.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__main .task-item__reason { color: var(--lx-text-soft); } +.task-item__state { align-self: start; margin-top: 2px; } +.task-item__hint { + max-width: 62px; + color: var(--lx-text-muted); + font-size: 9px; + font-weight: 600; + line-height: 1.25; + text-align: right; +} +.task-item__hint[data-status="waiting_for_user"] { color: var(--lx-warning); } +.task-item__hint[data-status="recovery_required"], .task-item__hint[data-status="failed"] { color: var(--lx-error); } +.task-item__hint--running { + max-width: none; + padding: 2px 6px; + border-radius: 7px; + background: color-mix(in srgb, var(--lx-info) 14%, transparent); + color: var(--lx-info); +} +.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 .repository-actions, .task-rail.is-collapsed .task-group__heading, .task-rail.is-collapsed .task-group > summary, .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 { container: loopx-main / inline-size; position: relative; display: flex; flex-direction: column; min-width: 0; min-height: 0; background: var(--lx-bg); } +.log-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + min-height: 54px; + padding: 7px 12px 7px 14px; + border-bottom: 1px solid var(--lx-border); +} +.log-title-block { flex: 1 1 320px; min-width: 240px; } +.log-title-row { display: flex; align-items: center; gap: 7px; min-width: 0; } +.log-title-row h1 { flex: 1 1 auto; min-width: 0; margin: 0; overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; } +.log-title-row .task-actions { flex: 0 0 auto; margin-left: 5px; } +.task-back-button { flex: 0 0 auto; } +.log-title-block p { margin: 2px 0 0; overflow: hidden; color: var(--lx-text-muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } +.liveness-item { + display: block; + max-width: 420px; + margin: 2px 0 0; + overflow: hidden; + color: var(--lx-text-muted); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; + text-decoration: none; +} +.liveness-item:hover { color: var(--lx-accent, inherit); text-decoration: underline; } +.liveness-item::after { content: "↗"; margin-left: 4px; text-decoration: none; } +.issue-description-panel { + flex: 0 0 auto; + border-bottom: 1px solid var(--lx-border); + background: var(--lx-surface); +} +.issue-description-panel > summary { + min-height: 30px; + padding: 6px 14px; + color: var(--lx-text-soft); + font-size: 10.5px; + font-weight: 600; + cursor: pointer; +} +.issue-description-panel > summary:hover { background: var(--lx-hover); } +.liveness-description { + max-height: 180px; + margin: 0; + padding: 3px 14px 12px; + overflow: auto; + color: var(--lx-text-soft); + font-size: 11.5px; + line-height: 1.65; + overflow-wrap: anywhere; + white-space: pre-wrap; +} +.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="cancelling"] { color: var(--lx-warning); } +.state-pill[data-state="failed"] { color: var(--lx-error); } +.log-controls { display: flex; align-items: center; justify-content: flex-end; flex: 0 1 auto; flex-wrap: wrap; 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); + 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-panel[data-state="running"] { + border-bottom-color: color-mix(in srgb, var(--lx-info) 34%, var(--lx-border)); + background: color-mix(in srgb, var(--lx-info) 8%, var(--lx-muted-surface)); +} +.liveness-panel[data-state="running"] .liveness-primary strong { color: var(--lx-info); } +.liveness-panel[data-state="cancelling"] { + border-bottom-color: color-mix(in srgb, var(--lx-warning) 40%, var(--lx-border)); + background: color-mix(in srgb, var(--lx-warning) 10%, var(--lx-muted-surface)); +} +.liveness-panel[data-state="cancelling"] .liveness-primary strong { color: var(--lx-warning); } +.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: 30px; padding: 4px 10px; font-size: 10.5px; white-space: nowrap; } +.task-actions .primary-button[data-action="resume"] { min-width: 78px; font-weight: 600; } +.task-actions button.is-pending { color: var(--lx-warning); } + +.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); + align-items: start; + 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--live { + border-left: 3px solid var(--lx-info); + background: color-mix(in srgb, var(--lx-info) 6%, transparent); +} +.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; } +.turn-output-row { + display: block; + min-height: 0; + margin: 6px 12px; + padding: 8px 10px; + border: 1px solid color-mix(in srgb, var(--lx-border) 76%, transparent); + border-left: 3px solid var(--lx-info); + border-radius: 8px; + background: color-mix(in srgb, var(--lx-elevated) 88%, transparent); +} +.turn-output-row[data-kind="thinking"] { border-left-color: var(--lx-text-muted); } +.turn-output-row[data-kind="tool"] { border-left-color: var(--lx-accent); } +.turn-output-row[data-kind="model_round_started"], .turn-output-row[data-kind="model_round_completed"] { + border-left-color: var(--lx-success); +} +.turn-output-row[data-level="error"] { border-left-color: var(--lx-error); } +.output-block__header { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + min-width: 0; + margin-bottom: 5px; +} +.output-block__source { + color: var(--lx-info); + font: 600 10px/1.2 var(--lx-font); +} +.output-block__cursor, +.output-block__meta { + color: var(--lx-text-muted); + font: 9px/1.2 var(--lx-mono); +} +.output-block__meta { + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.output-block__message { + color: var(--lx-text); + font: 11px/1.55 var(--lx-mono); + overflow-wrap: anywhere; + white-space: pre-wrap; +} +.turn-output-row[data-kind="thinking"] .output-block__message { color: var(--lx-text-soft); } +.turn-output-row[data-level="error"] .output-block__message { color: var(--lx-error); } +.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; +} +@container loopx-main (max-width: 780px) { + .log-header { align-items: stretch; flex-direction: column; } + .log-title-block { width: 100%; min-width: 0; } + .log-title-row { flex-wrap: wrap; } + .log-title-row h1 { flex-basis: calc(100% - 150px); } + .log-title-row .task-actions { margin-left: 0; } + .log-controls { width: 100%; justify-content: flex-start; } + .search-field { flex: 1 1 180px; width: auto; } + .liveness-panel { grid-template-columns: minmax(0, 1fr); align-items: start; } + .liveness-primary, .liveness-details, .task-actions { flex-wrap: wrap; } + .liveness-details { grid-column: 1; } + .task-actions { justify-content: flex-start; } + .task-actions .primary-button[data-action="resume"] { min-width: 112px; } +} + +.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__body > .dialog-note { margin-top: 9px; color: var(--lx-text-muted); font-size: 10px; } +.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) auto; } + .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) auto; gap: 7px; } + .product-mark { grid-column: 1; } + .intake-form { grid-column: 1 / -1; grid-row: 2; } + .header-actions { 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: 150px; } + .model-field__select { 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..1c9f8cfc65 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/test/thin-client-runtime.test.mjs @@ -0,0 +1,572 @@ +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.5.1', '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 turnOutputSince() { + return { + status: 'current', + taskId: task.taskId, + turnId: task.currentTurnId, + streamId: 'output-stream-1', + events: [], + nextCursor: 0, + hasMore: false, + message: null, + }; + }, + 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\.5\.1/); + 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 connectionChanges = []; + const connectionObserver = new window.MutationObserver(() => { + connectionChanges.push(window.document.querySelector('#connection-label').textContent); + }); + connectionObserver.observe(window.document.querySelector('#connection-label'), { + childList: true, + characterData: true, + subtree: true, + }); + eventListener({ + event: { + streamId: snapshot.streamId, + cursor: 4, + taskId: task.taskId, + generation: 2, + revision: 8, + kind: 'state_changed', + level: 'info', + source: 'controller', + phase: 'queued', + message: 'Task returned to the queue', + important: false, + details: {}, + occurredAt: now, + }, + }); + await waitFor(() => attachRequests.length === 2, 'background snapshot refresh'); + await new Promise((resolve) => setTimeout(resolve, 0)); + connectionObserver.disconnect(); + assert.equal(window.document.querySelector('#connection-label').textContent, 'Connected'); + assert.ok(!connectionChanges.includes('Resynchronizing')); + + 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(); + } +}); + +test('task rail prioritizes user decisions and keeps single failures out of repository bulk recovery', 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 makeTask = (taskId, number, state, phase, overrides = {}) => ({ + ...taskSnapshot(now), + taskId, + identity: { + item: issueKey(number), + attempt: 1, + title: `Issue ${number}`, + description: '', + }, + state, + phase, + currentTurnId: null, + currentTool: null, + error: null, + ...overrides, + }); + const waiting = makeTask('task-waiting', 42, 'waiting_for_user', 'waiting_for_approval'); + const failed = makeTask('task-failed', 43, 'recovery_required', 'recovering', { + error: 'LoopX process exited with status 1', + }); + const running = makeTask('task-running', 44, 'running', 'agent_running'); + const queued = makeTask('task-queued', 45, 'queued', 'queued'); + const snapshot = controllerSnapshot(now, running); + snapshot.tasks = [queued, running, failed, waiting]; + snapshot.cursor = 1; + snapshot.revision = 24; + const events = [{ + streamId: snapshot.streamId, + cursor: 1, + taskId: waiting.taskId, + generation: waiting.generation, + revision: waiting.revision, + kind: 'approval_required', + level: 'warning', + source: 'controller', + phase: 'waiting_for_approval', + message: 'Approve creating the draft pull request', + important: true, + details: { gateId: 'todo_release_approval', actionKind: 'gate' }, + occurredAt: now - 1000, + }]; + let eventListener = null; + window.app = { + locale: 'en-US', + loopx: { + onEvent(listener) { + eventListener = listener; + }, + offEvent(listener) { + assert.equal(listener, eventListener); + }, + async attach() { + return { snapshot: structuredClone(snapshot) }; + }, + async eventsSince() { + return { + status: 'current', + streamId: snapshot.streamId, + events: structuredClone(events), + nextCursor: snapshot.cursor, + hasMore: false, + }; + }, + async turnOutputSince() { + return { + status: 'current', + taskId: running.taskId, + turnId: running.currentTurnId, + streamId: 'output-stream-1', + events: [], + nextCursor: 0, + hasMore: false, + message: null, + }; + }, + async resolveIntake() { + return { preview: { candidates: [] } }; + }, + async action() { + assert.fail('this test must not submit a decision'); + }, + }, + onLocaleChange() {}, + onActivate() {}, + }; + + try { + window.eval(ui); + await waitFor( + () => window.document.querySelectorAll('#task-items .task-group').length === 4, + 'priority task groups', + ); + + const groups = [...window.document.querySelectorAll('#task-items .task-group')] + .map((group) => group.dataset.group); + assert.deepEqual(groups, ['decision', 'error', 'active', 'queued']); + assert.equal(window.document.querySelector('#decision-count').hidden, false); + assert.equal(window.document.querySelector('#decision-count').textContent, '1'); + assert.equal(window.document.querySelector('[data-group="queued"]').open, false); + assert.equal(window.document.querySelector('#repository-actions').hidden, true); + assert.match( + window.document.querySelector('[data-task-id="task-waiting"]').textContent, + /Review and decide/, + ); + assert.match( + window.document.querySelector('[data-task-id="task-failed"]').textContent, + /View error/, + ); + window.document.querySelector('#reset-loopx').click(); + assert.equal(window.document.querySelector('#reset-loopx-dialog').open, true); + assert.match(window.document.querySelector('#reset-loopx-message').textContent, /4 tasks, 1 log event/); + window.document.querySelector('#reset-loopx-cancel').click(); + assert.equal(window.document.querySelector('#reset-loopx-dialog').open, false); + + window.document.querySelector('[data-task-id="task-waiting"]').click(); + await waitFor( + () => /temporarily unavailable/.test(window.document.querySelector('#liveness-description').textContent), + 'selected task metadata refresh', + ); + const decisionButton = window.document.querySelector('#task-actions button'); + assert.equal(decisionButton.textContent, 'Review and decide'); + decisionButton.click(); + assert.equal(window.document.querySelector('#gate-dialog').open, true); + assert.match( + window.document.querySelector('#gate-message').textContent, + /Approve creating the draft pull request/, + ); + 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..8beb8b9e40 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/test/thin-client-source-contract.test.mjs @@ -0,0 +1,159 @@ +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', + 'turnOutputSince', + ]; + + 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, + 'listModels', + '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..d9cac7644f --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/ui.js @@ -0,0 +1,2966 @@ +'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 MAX_TURN_OUTPUT_EVENTS = 1200; +const MODEL_SELECTION_STORAGE_KEY = 'loopx.modelId'; +const HIGH_RISK_SCOPES = new Set([ + 'publish', + 'public_comment', + 'pull_request', + 'merge', + 'production_action', +]); +const KEY_EVENT_KINDS = new Set([ + 'task_created', + 'progress', + '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: '自动模型', + modelLoading: '正在加载模型…', + modelReloadTitle: '刷新模型列表', + modelSelectionChanged: '已切换模型,请重新分析链接。', + modelPrimaryTag: '主模型', + resolve: '分析链接', + resolving: '正在实时复核链接', + refresh: '刷新状态', + resetLoopx: '重置 LoopX', + resettingLoopx: '正在重置 LoopX…', + destructiveAction: '危险操作', + resetLoopxTitle: '清空并重新开始', + resetLoopxMessage: '将停止并删除 {tasks} 个任务、{events} 条日志、全部 Goal 状态和受管工作区。此操作不可撤销。', + resetLoopxRetained: '模型配置、GitHub 登录和 MiniApp 设置会保留。', + resetLoopxConfirm: '清空并重新开始', + resetLoopxApplied: 'LoopX 已清空,可以重新开始测试。', + unsupportedTitle: '当前执行位置不支持 LoopX', + unsupportedDefault: 'LoopX 目前只支持本地 Desktop 工作区。此入口不会回退到控制端执行。', + environment: '环境', + coreEnvironment: '核心环境', + optionalEnvironment: '增强能力', + required: '必需', + optional: '可选', + retryEnvironment: '重新检查环境', + tasks: '任务', + collapseTasks: '收起任务栏', + resizeTasks: '调整任务栏宽度', + expandTasks: '展开任务栏', + allActivity: '全部活动', + noTasks: '暂无任务', + activity: '运行日志', + allTaskEvents: '所有任务的实时日志和阶段进度', + selectedTaskEvents: '当前任务的事件与运行状态', + mainProgress: '进行中 {active} · 排队 {queued}', + mainProgressOnlyQueued: '排队 {queued} · 等待调度', + mainProgressIdle: '暂无进行中的任务', + mainProgressCurrent: '当前:{item}', + mainProgressPhase: '阶段:{phase}', + worktreeQuiet: '正在准备 Worktree:{item}。首次克隆可能需要几分钟;Git 静默时不会产生子进程输出。', + mainProgressWaiting: '正在同步任务状态;阶段变化和宿主输出会继续出现在这里。', + keyEvents: '关键事件', + fullLog: '完整日志', + liveOutput: '实时输出', + searchLogs: '搜索日志', + errorsOnly: '仅错误', + exportLogs: '导出日志', + noLogs: '暂无运行事件', + noLiveOutput: '暂无实时模型输出', + outputUnavailable: '实时输出暂不可用', + outputNotRunning: '当前任务没有正在运行的 Agent turn', + outputThinking: '思考', + outputTool: '工具', + outputModel: '模型', + outputText: '输出', + outputChunks: '{value} 段', + newEvents: '查看新事件', + 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: '恢复', + continueRun: '仅继续此任务', + resumeRepository: '恢复此仓库的异常任务({value})', + resumingRepository: '正在恢复异常任务…', + repositorySerial: '同仓库串行执行', + batchAction: '批量操作', + resumeRepositoryTitle: '恢复此仓库的异常任务', + confirmContinue: '确认继续', + resumeRepositoryMessage: '将恢复 {repository} 中 {value} 个失败或需要恢复的任务。同一时间只运行 1 个,其余任务进入队列。', + resumeRepositoryApplied: '已将 {value} 个异常任务加入仓库队列。', + repositoryPausedByModel: '模型请求失败,仓库队列已暂停', + fixModelBeforeContinue: '修复模型并重新检查环境后继续', + groupDecision: '待你决定', + groupError: '运行异常', + groupActive: '正在运行', + groupQueued: '排队中', + groupPaused: '已暂停', + groupCompleted: '已完成', + groupArchived: '已归档', + expandGroup: '展开', + collapseGroup: '收起', + expandGroupTitle: '展开 {label}({count})', + collapseGroupTitle: '收起 {label}({count})', + runningNow: '运行中', + decisionCountTitle: '待你决定的任务', + viewError: '查看错误', + archive: '归档并清理工作区', + restore: '还原', + retry: '重试', + details: '详情', + currentTool: '工具', + turn: 'Turn', + goal: 'Goal', + deadline: '截止', + noRecentOutput: '已 {duration} 没有新输出', + lastOutput: '最后输出 {duration} 前', + updated: '更新于 {duration} 前', + openInGithub: '在 GitHub 中打开', + issueDescription: 'Issue 描述', + loadingIssueDescription: '正在加载 Issue 描述…', + issueDescriptionUnavailable: '暂时无法加载 Issue 描述。', + justNow: '刚刚', + seconds: '{value} 秒', + minutes: '{value} 分钟', + hours: '{value} 小时', + days: '{value} 天', + deadlinePassed: '已超过截止时间 {duration}', + deadlineRemaining: '剩余 {duration}', + intakeUnavailable: '当前执行位置不支持创建任务。', + bridgeUnavailable: '宿主没有提供受信任的 LoopX 控制器接口。请更新 BitFun 后重试。', + selectAtLeastOne: '至少选择一个仍开放的 Issue 或 PR。', + selectAll: '全选', + selectPermissions: '请确认本次任务需要的权限范围。', + previewExpired: '确认信息已失效,请重新分析链接。', + taskCreated: '任务已创建。', + tasksCreated: '已创建 {value} 个任务。', + outcomeCount: '{message}({value} 项)', + selectedCandidates: '已选 {selected} / {total}', + batchSelection: '将为已选的 {value} 个不同 Issue / PR 分别创建独立任务,工作区会逐个准备。', + workspacePreparationFailed: '工作区准备失败', + queuedRepoBusy: '等待同仓库当前任务完成', + queuedBoundedWait: '回合之间等待调度(有界回合)', + openedExisting: '已打开现有任务,没有重复创建。', + closedNoop: '目标已关闭或合并,无需创建任务。', + liveVerification: '目标需要再次在线复核,暂未创建任务。', + retryRequired: '该目标已有终态任务。只有确认后才会创建新的 attempt。', + actionApplied: '操作已应用。', + actionPending: '正在提交操作', + pausePending: '正在暂停', + resumePending: '正在继续', + archivePending: '正在归档', + 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', + modelLoading: 'Loading models...', + modelReloadTitle: 'Refresh model list', + modelSelectionChanged: 'Model changed. Analyze the URL again.', + modelPrimaryTag: 'Primary', + resolve: 'Analyze URL', + resolving: 'Verifying URL against the live source', + refresh: 'Refresh status', + resetLoopx: 'Reset LoopX', + resettingLoopx: 'Resetting LoopX...', + destructiveAction: 'Destructive action', + resetLoopxTitle: 'Clear and start over', + resetLoopxMessage: 'Stop and delete {tasks} tasks, {events} log events, every Goal state, and all managed workspaces. This cannot be undone.', + resetLoopxRetained: 'Model configuration, GitHub login, and MiniApp settings are retained.', + resetLoopxConfirm: 'Clear and start over', + resetLoopxApplied: 'LoopX was cleared. You can start a fresh test.', + 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', + resizeTasks: 'Resize task rail', + expandTasks: 'Expand task rail', + allActivity: 'All activity', + noTasks: 'No tasks yet', + activity: 'Run log', + allTaskEvents: 'Live logs and phase progress for every task', + selectedTaskEvents: 'Events and liveness for the selected task', + mainProgress: '{active} active · {queued} queued', + mainProgressOnlyQueued: '{queued} queued · waiting for the scheduler', + mainProgressIdle: 'No tasks are currently active', + mainProgressCurrent: 'Current: {item}', + mainProgressPhase: 'Phase: {phase}', + worktreeQuiet: 'Preparing worktree: {item}. The first clone can take a few minutes; Git may not emit output while it is working.', + mainProgressWaiting: 'Syncing task state; phase changes and host output will continue to appear here.', + keyEvents: 'Key events', + fullLog: 'Full log', + liveOutput: 'Live output', + searchLogs: 'Search logs', + errorsOnly: 'Errors only', + exportLogs: 'Export logs', + noLogs: 'No run events yet', + noLiveOutput: 'No live model output yet', + outputUnavailable: 'Live output is unavailable', + outputNotRunning: 'This task has no running Agent turn', + outputThinking: 'Thinking', + outputTool: 'Tool', + outputModel: 'Model', + outputText: 'Output', + outputChunks: '{value} chunks', + newEvents: 'View new events', + 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: 'Review and decide', + approvalNote: 'Approval note', + approvalNotePlaceholder: 'Optional reason for approving or rejecting', + reject: 'Reject', + approve: 'Approve', + pause: 'Pause', + resume: 'Resume', + continueRun: 'Continue only this task', + resumeRepository: 'Recover repository failures ({value})', + resumingRepository: 'Recovering failed tasks...', + repositorySerial: 'Runs serially per repository', + batchAction: 'Batch action', + resumeRepositoryTitle: 'Recover repository failures', + confirmContinue: 'Continue tasks', + resumeRepositoryMessage: 'Recover {value} failed or recovery-required tasks in {repository}. One task runs at a time; the rest remain queued.', + resumeRepositoryApplied: 'Queued {value} failed repository tasks.', + repositoryPausedByModel: 'Model request failed; repository queue paused', + fixModelBeforeContinue: 'Fix the model and recheck the environment', + groupDecision: 'Waiting for you', + groupError: 'Run failures', + groupActive: 'Running', + groupQueued: 'Queued', + groupPaused: 'Paused', + groupCompleted: 'Completed', + groupArchived: 'Archived', + expandGroup: 'Expand', + collapseGroup: 'Collapse', + expandGroupTitle: 'Expand {label} ({count})', + collapseGroupTitle: 'Collapse {label} ({count})', + runningNow: 'Running', + decisionCountTitle: 'Tasks waiting for your decision', + viewError: 'View error', + archive: 'Archive & clean workspace', + restore: 'Restore', + retry: 'Retry', + details: 'Details', + currentTool: 'tool', + turn: 'Turn', + goal: 'Goal', + deadline: 'deadline', + noRecentOutput: 'No new output for {duration}', + lastOutput: 'Last output {duration} ago', + updated: 'Updated {duration} ago', + openInGithub: 'Open in GitHub', + issueDescription: 'Issue description', + loadingIssueDescription: 'Loading issue description...', + issueDescriptionUnavailable: 'Issue description is temporarily unavailable.', + 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.', + selectAll: 'Select all', + selectPermissions: 'Confirm the permission scopes required by this run.', + previewExpired: 'This preview is stale. Analyze the URL again.', + taskCreated: 'Task created.', + tasksCreated: '{value} tasks created.', + outcomeCount: '{message} ({value} items)', + selectedCandidates: '{selected} / {total} selected', + batchSelection: 'Each of the {value} selected issues or pull requests will become a separate task. Workspaces are prepared one at a time.', + workspacePreparationFailed: 'Workspace setup failed', + queuedRepoBusy: 'Waiting for the active task in this repository', + queuedBoundedWait: 'Between bounded turns', + 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.', + actionPending: 'Applying action', + pausePending: 'Pausing', + resumePending: 'Continuing', + archivePending: 'Archiving', + 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 syncing', + 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'), + resetLoopx: byId('reset-loopx'), + 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'), + railSplitter: byId('rail-splitter'), + collapseTasks: byId('collapse-tasks'), + taskCount: byId('task-count'), + decisionCount: byId('decision-count'), + repositoryActions: byId('repository-actions'), + resumeRepository: byId('resume-repository'), + repositoryActionsMeta: byId('repository-actions-meta'), + taskItems: byId('task-items'), + taskEmpty: byId('task-empty'), + showAllEvents: byId('show-all-events'), + logTitle: byId('log-title'), + selectedState: byId('selected-state'), + selectedSummary: byId('selected-summary'), + modeKey: byId('mode-key'), + modeFull: byId('mode-full'), + modeOutput: byId('mode-output'), + 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'), + livenessGoal: byId('liveness-goal'), + livenessError: byId('liveness-error'), + livenessItem: byId('liveness-item'), + issueDescriptionPanel: byId('issue-description-panel'), + livenessDescription: byId('liveness-description'), + taskActions: byId('task-actions'), + logScroll: byId('log-scroll'), + logEmpty: byId('log-empty'), + logList: byId('log-list'), + newEvents: byId('new-events'), + 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'), + candidateSelectAll: byId('candidate-select-all'), + 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'), + repositoryResumeDialog: byId('repository-resume-dialog'), + repositoryResumeMessage: byId('repository-resume-message'), + repositoryResumeCancel: byId('repository-resume-cancel'), + repositoryResumeConfirm: byId('repository-resume-confirm'), + resetLoopxDialog: byId('reset-loopx-dialog'), + resetLoopxMessage: byId('reset-loopx-message'), + resetLoopxCancel: byId('reset-loopx-cancel'), + resetLoopxConfirm: byId('reset-loopx-confirm'), +}; + +const state = { + snapshot: null, + events: [], + eventKeys: new Set(), + selectedTaskId: null, + logMode: 'full', + query: '', + errorsOnly: false, + followLogs: true, + preview: null, + pendingCreate: null, + pendingRetry: null, + gate: null, + syncing: false, + syncRequested: false, + gapRecovery: null, + connected: false, + railCollapsed: false, + taskGroupOpen: {}, + visibleEvents: [], + turnOutput: { + taskId: null, + turnId: null, + streamId: null, + cursor: 0, + events: [], + message: '', + status: 'not_running', + inFlight: false, + timer: null, + }, + itemMetadata: new Map(), + metadataRequests: new Set(), + repositoryResumeTarget: null, + repositoryResumePending: false, + taskActionPending: new Map(), + modelCatalogLoading: false, + modelCatalogLoaded: false, + resetPending: false, +}; + +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 isWorkspacePreparationFailure(task) { + return Boolean(task && task.error && !task.workspacePath && !task.goalId); +} + +function taskStateLabel(task) { + return isWorkspacePreparationFailure(task) ? stateLabel('failed') : stateLabel(task && task.state); +} + +function pendingActionFor(task) { + return task && task.taskId ? state.taskActionPending.get(task.taskId) : ''; +} + +function taskVisualState(task) { + const pending = pendingActionFor(task); + if (pending === 'pause' || pending === 'abort') return 'cancelling'; + return isWorkspacePreparationFailure(task) + ? 'failed' + : ((task && task.state) || 'recovery_required'); +} + +function taskStateDisplayLabel(task) { + const pending = pendingActionFor(task); + if (pending === 'pause' || pending === 'abort') return text('pausePending'); + if (pending === 'resume' || pending === 'restore') return text('resumePending'); + if (pending === 'archive') return text('archivePending'); + if (pending) return text('actionPending'); + return taskStateLabel(task); +} + +function taskPhaseLabel(task) { + return isWorkspacePreparationFailure(task) + ? text('workspacePreparationFailed') + : phaseLabel(task && task.phase); +} + +function compactItemLabel(item) { + if (!item) return '--'; + return `${item.kind === 'pr' ? 'PR' : 'Issue'} #${item.number}`; +} + +function shortId(value) { + const raw = value == null ? '' : String(value); + return raw.length > 14 ? raw.slice(0, 8) : raw; +} + +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 readStoredModelSelection() { + try { + return window.localStorage.getItem(MODEL_SELECTION_STORAGE_KEY) || ''; + } catch (_error) { + return ''; + } +} + +function writeStoredModelSelection(value) { + try { + window.localStorage.setItem(MODEL_SELECTION_STORAGE_KEY, value || 'auto'); + } catch (_error) { + // Ignore storage failures; the current select value still applies. + } +} + +function currentModelSelection() { + const stored = readStoredModelSelection(); + const selected = view.modelSelect && view.modelSelect.value ? view.modelSelect.value : ''; + if (selected && (selected !== 'auto' || !stored || stored === 'auto')) return selected; + return stored || selected || 'auto'; +} + +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 itemUrl(item) { + if (!item || !item.repository) return ''; + const { host, owner, repository } = item.repository; + if (!host || !owner || !repository || !item.number) return ''; + const path = item.kind === 'pr' ? 'pull' : 'issues'; + return `https://${host}/${owner}/${repository}/${path}/${item.number}`; +} + +function identityTitleOf(task) { + const title = task && task.identity && task.identity.title; + if (typeof title === 'string' && title.trim()) return title.trim(); + const item = task && task.identity && task.identity.item; + return (state.itemMetadata.get(itemKey(item)) || {}).title || ''; +} + +function identityDescriptionOf(task) { + const description = task && task.identity && task.identity.description; + if (typeof description === 'string' && description.trim()) return description.trim(); + const item = task && task.identity && task.identity.item; + return (state.itemMetadata.get(itemKey(item)) || {}).description || ''; +} + +function latestTaskWaitReason(task) { + if (!task || task.state !== 'queued') return ''; + for (let index = state.events.length - 1; index >= 0; index -= 1) { + const event = state.events[index]; + if (event.taskId !== task.taskId || !event.message) continue; + const message = String(event.message); + if (/another task for this repository/i.test(message)) return text('queuedRepoBusy'); + if (/bounded turn/i.test(message)) return text('queuedBoundedWait'); + return message; + } + return text('queuedRepoBusy'); +} + +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 runningOutputTask() { + const selected = selectedTask(); + if (selected && selected.state === 'running') return selected; + const tasks = state.snapshot && Array.isArray(state.snapshot.tasks) + ? state.snapshot.tasks + : []; + return tasks.find((task) => task.state === 'running') || null; +} + +function resetTurnOutput(task) { + state.turnOutput.taskId = task ? task.taskId : null; + state.turnOutput.turnId = task ? (task.currentTurnId || null) : null; + state.turnOutput.streamId = null; + state.turnOutput.cursor = 0; + state.turnOutput.events = []; + state.turnOutput.message = ''; + state.turnOutput.status = task ? 'current' : 'not_running'; +} + +function ensureTurnOutputTarget(task) { + const currentTurn = task && task.currentTurnId ? task.currentTurnId : null; + if ( + state.turnOutput.taskId !== (task && task.taskId) + || state.turnOutput.turnId !== currentTurn + ) { + resetTurnOutput(task); + } +} + +function clearTurnOutputTimer() { + if (state.turnOutput.timer) { + clearTimeout(state.turnOutput.timer); + state.turnOutput.timer = null; + } +} + +function scheduleTurnOutputPoll(delay = 1200) { + clearTurnOutputTimer(); + if (state.logMode !== 'output') return; + const task = runningOutputTask(); + if (!task || !app || !app.loopx || typeof app.loopx.turnOutputSince !== 'function') return; + state.turnOutput.timer = setTimeout(() => { + state.turnOutput.timer = null; + void refreshTurnOutput(); + }, delay); +} + +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; + 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 }; +} + +async function refreshTurnOutput() { + if (state.turnOutput.inFlight || state.logMode !== 'output') return; + const task = runningOutputTask(); + if (!task) { + resetTurnOutput(null); + renderLogs(); + return; + } + ensureTurnOutputTarget(task); + if (!app || !app.loopx || typeof app.loopx.turnOutputSince !== 'function') { + state.turnOutput.status = 'output_unavailable'; + state.turnOutput.message = text('outputUnavailable'); + renderLogs(); + return; + } + + state.turnOutput.inFlight = true; + try { + const page = await app.loopx.turnOutputSince({ + taskId: task.taskId, + ...(state.turnOutput.turnId ? { turnId: state.turnOutput.turnId } : {}), + ...(state.turnOutput.streamId ? { streamId: state.turnOutput.streamId } : {}), + afterCursor: state.turnOutput.cursor, + limit: 200, + }); + if (!page || page.taskId !== task.taskId) { + state.turnOutput.status = 'output_unavailable'; + state.turnOutput.message = text('outputUnavailable'); + return; + } + if (page.turnId && page.turnId !== state.turnOutput.turnId) { + resetTurnOutput({ ...task, currentTurnId: page.turnId }); + } + if (page.streamId && page.streamId !== state.turnOutput.streamId) { + state.turnOutput.streamId = page.streamId; + state.turnOutput.cursor = 0; + state.turnOutput.events = []; + } + state.turnOutput.status = page.status || 'current'; + state.turnOutput.message = page.message || ''; + (page.events || []).forEach((event) => { + if (!Number.isSafeInteger(event.cursor)) return; + if (state.turnOutput.events.some((existing) => existing.cursor === event.cursor)) return; + state.turnOutput.events.push(event); + }); + state.turnOutput.events.sort((left, right) => left.cursor - right.cursor); + while (state.turnOutput.events.length > MAX_TURN_OUTPUT_EVENTS) { + state.turnOutput.events.shift(); + } + state.turnOutput.cursor = Math.max( + state.turnOutput.cursor, + Number(page.nextCursor || state.turnOutput.cursor), + ); + if (page.hasMore) scheduleTurnOutputPoll(0); + else scheduleTurnOutputPoll(1200); + } catch (error) { + state.turnOutput.status = 'output_unavailable'; + state.turnOutput.message = error instanceof Error ? error.message : String(error); + scheduleTurnOutputPoll(3000); + } finally { + state.turnOutput.inFlight = false; + renderLogs(); + } +} + +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); + if (!view.repositoryActions.hidden) view.resumeRepository.disabled = true; + try { + do { + state.syncRequested = false; + const knownStreamId = state.snapshot && state.snapshot.streamId; + const afterCursor = state.snapshot && state.snapshot.cursor; + if (!state.connected) view.connectionLabel.textContent = text('connecting'); + const response = await app.loopx.attach({ + ...(knownStreamId ? { knownStreamId } : {}), + ...(Number.isSafeInteger(afterCursor) ? { afterCursor } : {}), + }); + applySnapshot(response && response.snapshot); + void loadModelCatalog(); + 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(); + } + } while (state.syncRequested); + } catch (error) { + state.connected = false; + view.connectionLabel.textContent = text('connectionFailed'); + showNotice(errorMessage(error), 'error'); + } finally { + state.syncing = false; + setButtonBusy(view.syncButton, false); + const tasks = state.snapshot && Array.isArray(state.snapshot.tasks) + ? state.snapshot.tasks + : []; + renderRepositoryActions(tasks); + } +} + +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(); + } 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 (event.kind === 'snapshot_invalidated' && event.cursor === 0) { + state.syncRequested = true; + queueMicrotask(() => void attachSnapshot(false)); + 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(); + } + 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(); + const environmentStatus = snapshot && snapshot.environment && snapshot.environment.status; + const coreReady = environmentStatus === 'ready' || environmentStatus === 'degraded'; + view.unsupportedBanner.hidden = !snapshot || supported; + if (snapshot && !supported) { + view.unsupportedReason.textContent = snapshot.unsupportedReason || text('unsupportedDefault'); + } + view.resolveButton.disabled = !supported || !coreReady; + 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), + ); +} + +const DECISION_TASK_STATES = new Set(['waiting_for_user']); +const ERROR_TASK_STATES = new Set(['recovery_required', 'failed']); +const ACTIVE_TASK_STATES = new Set([ + 'running', + 'preparing', + 'cancelling', +]); +const QUEUED_TASK_STATES = new Set(['queued', 'retry_wait']); +const GLOBAL_PROGRESS_TASK_STATES = new Set([...ACTIVE_TASK_STATES, ...QUEUED_TASK_STATES]); + +function repositoryKey(repository) { + return repository + ? `${repository.host || ''}/${repository.owner || ''}/${repository.repository || ''}` + : ''; +} + +function taskGroupKey(task) { + if (DECISION_TASK_STATES.has(task.state)) return 'decision'; + if (ERROR_TASK_STATES.has(task.state)) return 'error'; + if (ACTIVE_TASK_STATES.has(task.state)) return 'active'; + if (QUEUED_TASK_STATES.has(task.state)) return 'queued'; + if (task.state === 'stopped') return 'paused'; + if (task.state === 'archived') return 'archived'; + if (task.state === 'completed') return 'completed'; + return 'error'; +} + +function taskSortPriority(task) { + const priorities = { + running: 0, + waiting_for_user: 1, + preparing: 2, + cancelling: 3, + queued: 4, + retry_wait: 5, + failed: 10, + recovery_required: 11, + stopped: 12, + }; + return priorities[task.state] ?? 20; +} + +function sortedTaskList(tasks) { + return [...tasks].sort((left, right) => + taskSortPriority(left) - taskSortPriority(right) + || Number(right.updatedAt || 0) - Number(left.updatedAt || 0)); +} + +function primaryProgressTask(tasks) { + return sortedTaskList(tasks).find((task) => GLOBAL_PROGRESS_TASK_STATES.has(task.state)) || null; +} + +function progressCounts(tasks) { + return { + active: tasks.filter((task) => ACTIVE_TASK_STATES.has(task.state)).length, + queued: tasks.filter((task) => QUEUED_TASK_STATES.has(task.state)).length, + }; +} + +function progressSummary(counts) { + if (counts.active > 0) { + return text('mainProgress', counts); + } + if (counts.queued > 0) { + return text('mainProgressOnlyQueued', counts); + } + return text('mainProgressIdle'); +} + +function progressItemLabel(task) { + const item = task && task.identity && task.identity.item; + return identityTitleOf(task) || compactItemLabel(item); +} + +function activeWorkEmptyMessage() { + const tasks = state.snapshot && Array.isArray(state.snapshot.tasks) ? state.snapshot.tasks : []; + const task = primaryProgressTask(tasks); + if (!task) return text('noLogs'); + if (task.phase === 'preparing_workspace') { + return text('worktreeQuiet', { item: progressItemLabel(task) }); + } + return text('mainProgressWaiting'); +} + +function taskStatusIcon(task) { + const status = isWorkspacePreparationFailure(task) ? 'failed' : task.state; + const symbols = { + running: '▶', + preparing: '…', + queued: '◷', + retry_wait: '◷', + waiting_for_user: '?', + cancelling: '■', + recovery_required: '↻', + failed: '!', + stopped: 'Ⅱ', + completed: '✓', + archived: '□', + }; + return { status, symbol: symbols[status] || '•' }; +} + +function recoverableTasksForRepository(tasks, repository) { + const key = repositoryKey(repository); + return tasks.filter((task) => + ERROR_TASK_STATES.has(task.state) + && repositoryKey(task.identity && task.identity.item && task.identity.item.repository) === key); +} + +function renderRepositoryActions(tasks) { + const selected = selectedTask(); + const eligibleRepositories = new Map(); + tasks.forEach((task) => { + if (!ERROR_TASK_STATES.has(task.state)) return; + const repository = task.identity && task.identity.item && task.identity.item.repository; + const key = repositoryKey(repository); + if (key && !eligibleRepositories.has(key)) eligibleRepositories.set(key, repository); + }); + const selectedRepository = selected + && selected.identity + && selected.identity.item + && selected.identity.item.repository; + const repository = selectedRepository && eligibleRepositories.has(repositoryKey(selectedRepository)) + ? selectedRepository + : (eligibleRepositories.size === 1 ? [...eligibleRepositories.values()][0] : null); + const eligible = repository ? recoverableTasksForRepository(tasks, repository) : []; + state.repositoryResumeTarget = repository && eligible.length > 1 + ? { repository, tasks: eligible } + : null; + view.repositoryActions.hidden = !state.repositoryResumeTarget; + if (!state.repositoryResumeTarget) return; + const modelStatus = state.snapshot + && state.snapshot.environment + && state.snapshot.environment.core + && state.snapshot.environment.core.agentModel + && state.snapshot.environment.core.agentModel.status; + const modelBlocked = modelStatus === 'degraded' || modelStatus === 'unavailable'; + view.resumeRepository.disabled = modelBlocked || state.repositoryResumePending || state.syncing; + view.resumeRepository.textContent = state.repositoryResumePending + ? text('resumingRepository') + : text('resumeRepository', { value: eligible.length }); + view.repositoryActionsMeta.textContent = modelBlocked + ? text('repositoryPausedByModel') + : `${repositoryLabel(repository)} · ${text('repositorySerial')}`; +} + +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'); + const statusIcon = taskStatusIcon(task); + icon.dataset.status = statusIcon.status; + icon.textContent = statusIcon.symbol; + + const main = document.createElement('span'); + main.className = 'task-item__main'; + const label = document.createElement('strong'); + const item = task.identity && task.identity.item; + const identityTitle = identityTitleOf(task); + label.textContent = identityTitle || compactItemLabel(item); + const meta = document.createElement('small'); + const activity = task.lastOutputAt || task.updatedAt; + meta.textContent = `${repositoryLabel(item && item.repository)} · ${compactItemLabel(item)} · ${relativeLabel(activity)}`; + main.append(label, meta); + const gate = task.state === 'waiting_for_user' ? latestGate(task.taskId) : null; + const reasonText = gate && gate.event.message + ? gate.event.message + : (ERROR_TASK_STATES.has(task.state) ? task.error : ''); + if (reasonText) { + const reason = document.createElement('small'); + reason.className = 'task-item__reason'; + reason.textContent = reasonText; + main.append(reason); + } + if (task.state === 'queued') { + const reason = latestTaskWaitReason(task); + if (reason) main.title = reason; + } + + const taskState = document.createElement('span'); + taskState.className = 'task-item__state'; + const pendingAction = pendingActionFor(task); + const visualState = taskVisualState(task); + button.dataset.state = visualState; + if (pendingAction) button.dataset.pending = pendingAction; + taskState.dataset.status = visualState; + if (pendingAction) { + taskState.classList.add('task-item__hint', 'task-item__hint--pending'); + taskState.textContent = taskStateDisplayLabel(task); + } else if (task.state === 'waiting_for_user' || ERROR_TASK_STATES.has(visualState)) { + taskState.classList.add('task-item__hint'); + taskState.textContent = task.state === 'waiting_for_user' + ? text('approvalGate') + : text('viewError'); + } else if (visualState === 'running') { + taskState.classList.add('task-item__hint', 'task-item__hint--running'); + taskState.textContent = text('runningNow'); + } else { + taskState.classList.add('status-dot'); + } + taskState.title = taskStateDisplayLabel(task); + button.setAttribute('aria-label', `${label.textContent}, ${taskStateDisplayLabel(task)}`); + button.append(icon, main, taskState); + button.addEventListener('click', () => selectTask(task.taskId)); + return button; +} + +function updateTaskGroupHeading(heading, definition, count, open) { + if (!definition.collapsible) return; + const toggle = heading.querySelector('.task-group__toggle'); + if (toggle) toggle.textContent = text(open ? 'collapseGroup' : 'expandGroup'); + const title = text(open ? 'collapseGroupTitle' : 'expandGroupTitle', { + label: definition.label, + count, + }); + heading.title = title; + heading.setAttribute('aria-label', title); + heading.setAttribute('aria-expanded', String(open)); +} + +function renderTasks() { + const tasks = state.snapshot && Array.isArray(state.snapshot.tasks) ? state.snapshot.tasks : []; + const fragment = document.createDocumentFragment(); + const definitions = [ + { key: 'decision', label: text('groupDecision'), collapsible: false }, + { key: 'error', label: text('groupError'), collapsible: false }, + { key: 'active', label: text('groupActive'), collapsible: false }, + { key: 'queued', label: text('groupQueued'), collapsible: true }, + { key: 'paused', label: text('groupPaused'), collapsible: true }, + { key: 'completed', label: text('groupCompleted'), collapsible: true }, + { key: 'archived', label: text('groupArchived'), collapsible: true }, + ]; + definitions.forEach((definition) => { + const grouped = sortedTaskList(tasks.filter((task) => taskGroupKey(task) === definition.key)); + if (!grouped.length) return; + const group = document.createElement(definition.collapsible ? 'details' : 'section'); + group.className = 'task-group'; + group.dataset.group = definition.key; + if (definition.collapsible) { + const saved = Object.prototype.hasOwnProperty.call(state.taskGroupOpen, definition.key) + ? state.taskGroupOpen[definition.key] + : undefined; + group.open = saved === undefined + ? grouped.some((task) => task.taskId === state.selectedTaskId) + : Boolean(saved); + } + const heading = document.createElement(definition.collapsible ? 'summary' : 'div'); + heading.className = 'task-group__heading'; + const label = document.createElement('span'); + label.className = 'task-group__label'; + if (definition.collapsible) { + const chevron = document.createElement('span'); + chevron.className = 'task-group__chevron'; + chevron.setAttribute('aria-hidden', 'true'); + label.append(chevron); + } + const labelText = document.createElement('span'); + labelText.className = 'task-group__label-text'; + labelText.textContent = definition.label; + label.append(labelText); + const meta = document.createElement('span'); + meta.className = 'task-group__meta'; + const count = document.createElement('span'); + count.className = 'task-group__count'; + count.textContent = String(grouped.length); + meta.append(count); + if (definition.collapsible) { + const toggle = document.createElement('span'); + toggle.className = 'task-group__toggle'; + meta.append(toggle); + group.addEventListener('toggle', () => { + state.taskGroupOpen[definition.key] = group.open; + updateTaskGroupHeading(heading, definition, grouped.length, group.open); + }); + } + heading.append(label, meta); + if (definition.collapsible) { + updateTaskGroupHeading(heading, definition, grouped.length, group.open); + } + const items = document.createElement('div'); + grouped.forEach((task) => items.append(taskButton(task))); + if (definition.key === 'error') group.append(heading, view.repositoryActions, items); + else group.append(heading, items); + fragment.append(group); + }); + view.taskItems.replaceChildren(fragment); + view.taskCount.textContent = String(tasks.length); + const decisionCount = tasks.filter((task) => DECISION_TASK_STATES.has(task.state)).length; + view.decisionCount.hidden = decisionCount === 0; + view.decisionCount.textContent = String(decisionCount); + view.decisionCount.title = text('decisionCountTitle'); + view.decisionCount.setAttribute('aria-label', `${text('decisionCountTitle')}: ${decisionCount}`); + view.taskEmpty.hidden = tasks.length !== 0; + renderRepositoryActions(tasks); +} + +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' + : (tone === 'primary' ? 'primary-button' : 'text-button'); + button.dataset.action = action; + button.textContent = label; + button.addEventListener('click', () => { + if (action === 'gate') openGateDialog(task); + else void performAction(action, task); + }); + return button; +} + +function makePendingActionButton(task) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'text-button is-pending'; + button.disabled = true; + button.textContent = taskStateDisplayLabel(task); + return button; +} + +function renderTaskActions(task) { + view.taskActions.replaceChildren(); + if (!task || !snapshotSupported()) return; + const fragment = document.createDocumentFragment(); + if (pendingActionFor(task)) { + fragment.append(makePendingActionButton(task)); + view.taskActions.append(fragment); + return; + } + if (['preparing', 'queued', 'running', 'retry_wait'].includes(task.state)) { + fragment.append(makeActionButton(text('pause'), 'pause', task)); + } + if (['stopped', 'recovery_required', 'failed'].includes(task.state)) { + const modelStatus = state.snapshot + && state.snapshot.environment + && state.snapshot.environment.core + && state.snapshot.environment.core.agentModel + && state.snapshot.environment.core.agentModel.status; + const modelBlocked = modelStatus === 'degraded' || modelStatus === 'unavailable'; + const resumeButton = makeActionButton( + modelBlocked + ? text('fixModelBeforeContinue') + : (isWorkspacePreparationFailure(task) ? text('retry') : text('continueRun')), + 'resume', + task, + 'primary', + ); + resumeButton.disabled = modelBlocked; + fragment.append(resumeButton); + } + 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(); + const tasks = state.snapshot && Array.isArray(state.snapshot.tasks) ? state.snapshot.tasks : []; + view.selectedState.hidden = !task; + view.showAllEvents.hidden = !task; + if (!task) { + const primary = primaryProgressTask(tasks); + const counts = progressCounts(tasks); + const showProgress = Boolean(primary || counts.queued > 0); + view.livenessPanel.hidden = !showProgress; + view.selectedSummary.hidden = false; + view.selectedSummary.textContent = text('allTaskEvents'); + view.logTitle.textContent = text('activity'); + view.livenessItem.hidden = true; + view.livenessItem.removeAttribute('href'); + view.issueDescriptionPanel.hidden = true; + view.livenessDescription.textContent = ''; + renderTaskActions(null); + if (!showProgress) { + view.livenessPanel.removeAttribute('data-state'); + view.livenessGoal.hidden = true; + view.livenessGoal.textContent = ''; + view.livenessTurn.hidden = true; + view.livenessTurn.textContent = ''; + view.livenessTool.hidden = true; + view.livenessTool.textContent = ''; + view.livenessDeadline.hidden = true; + view.livenessDeadline.textContent = ''; + view.livenessError.hidden = true; + view.livenessError.textContent = ''; + return; + } + const visualState = primary ? taskVisualState(primary) : 'queued'; + view.livenessPanel.dataset.state = visualState; + view.livenessDot.dataset.status = visualState; + view.livenessPhase.textContent = primary + ? (pendingActionFor(primary) ? taskStateDisplayLabel(primary) : taskPhaseLabel(primary)) + : text('groupQueued'); + view.livenessSince.textContent = progressSummary(counts); + view.livenessTurn.hidden = !primary; + view.livenessTurn.textContent = primary + ? text('mainProgressCurrent', { item: progressItemLabel(primary) }) + : ''; + view.livenessTool.hidden = !primary; + view.livenessTool.textContent = primary + ? text('mainProgressPhase', { phase: taskPhaseLabel(primary) }) + : ''; + const waitReason = primary && primary.state === 'queued' ? latestTaskWaitReason(primary) : ''; + view.livenessDeadline.hidden = !waitReason; + view.livenessDeadline.textContent = waitReason; + view.livenessGoal.hidden = !primary || !primary.goalId; + view.livenessGoal.textContent = primary && primary.goalId ? `${text('goal')}: ${primary.goalId}` : ''; + view.livenessError.hidden = !primary || !primary.error; + view.livenessError.textContent = primary && primary.error ? primary.error : ''; + return; + } + view.livenessPanel.hidden = false; + const selectedVisualState = taskVisualState(task); + view.livenessPanel.dataset.state = selectedVisualState; + view.logTitle.textContent = identityTitleOf(task) || itemLabel(task.identity && task.identity.item); + view.selectedSummary.hidden = true; + view.selectedState.hidden = false; + view.selectedState.dataset.state = selectedVisualState; + view.selectedState.textContent = taskStateDisplayLabel(task); + view.livenessDot.dataset.status = selectedVisualState; + view.livenessPhase.textContent = pendingActionFor(task) ? taskStateDisplayLabel(task) : taskPhaseLabel(task); + + const item = task.identity && task.identity.item; + if (item && item.repository) { + const itemLabelText = itemLabel(item); + view.livenessItem.hidden = false; + view.livenessItem.textContent = itemLabelText; + view.livenessItem.href = itemUrl(item); + view.livenessItem.setAttribute('aria-label', `${text('openInGithub')}: ${itemLabelText}`); + view.livenessItem.removeAttribute('title'); + } else { + view.livenessItem.hidden = true; + view.livenessItem.removeAttribute('href'); + view.livenessItem.removeAttribute('aria-label'); + view.livenessItem.textContent = ''; + } + const description = identityDescriptionOf(task); + const metadataKey = itemKey(item); + const loadingDescription = state.metadataRequests.has(metadataKey); + const descriptionUnavailable = (state.itemMetadata.get(metadataKey) || {}).unavailable === true; + view.issueDescriptionPanel.hidden = !description && !loadingDescription && !descriptionUnavailable; + view.livenessDescription.textContent = description + || (loadingDescription ? text('loadingIssueDescription') : text('issueDescriptionUnavailable')); + view.livenessDescription.removeAttribute('title'); + + 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')}: ${shortId(task.currentTurnId)}` : ''; + view.livenessGoal.hidden = !task.goalId; + view.livenessGoal.textContent = task.goalId ? `${text('goal')}: ${task.goalId}` : ''; + 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) { + const task = taskForId(event.taskId); + const item = task && task.identity && task.identity.item; + metaValues.push(item ? compactItemLabel(item) : 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 liveProgressRow(task, counts) { + const row = document.createElement('li'); + row.className = 'log-row log-row--live'; + row.dataset.level = 'info'; + + const time = document.createElement('span'); + time.className = 'log-time'; + time.textContent = 'LIVE'; + + const level = document.createElement('span'); + level.className = 'event-level'; + level.dataset.level = 'info'; + level.textContent = 'NOW'; + + const source = document.createElement('span'); + source.className = 'log-source'; + source.textContent = 'controller'; + + const content = document.createElement('div'); + content.className = 'log-content'; + const message = document.createElement('div'); + message.className = 'log-message'; + message.textContent = task.phase === 'preparing_workspace' + ? text('worktreeQuiet', { item: progressItemLabel(task) }) + : `${taskPhaseLabel(task)} · ${progressItemLabel(task)}`; + content.append(message); + + const meta = document.createElement('div'); + meta.className = 'log-meta'; + [ + progressSummary(counts), + taskStateLabel(task), + relativeLabel(task.lastOutputAt || task.updatedAt), + ].forEach((value) => { + const span = document.createElement('span'); + span.textContent = value; + meta.append(span); + }); + content.append(meta); + row.append(time, level, source, content); + return row; +} + +function outputKindLabel(kind) { + if (kind === 'thinking') return text('outputThinking'); + if (kind === 'tool') return text('outputTool'); + if (kind === 'model_round_started' || kind === 'model_round_completed') return text('outputModel'); + return text('outputText'); +} + +function appendOutputText(existing, next) { + const value = next == null ? '' : String(next); + if (!value) return existing; + return existing ? `${existing}${value}` : value; +} + +function outputEventFallbackText(event) { + if (event.text) return event.text; + if (event.toolState) return event.toolState; + if (event.kind === 'model_round_started') return 'Model round started'; + if (event.kind === 'model_round_completed') return 'Model round completed'; + return event.kind || text('outputText'); +} + +function canMergeOutputEvent(event) { + return event.kind === 'thinking' || event.kind === 'text'; +} + +function compactTurnOutputBlocks(events) { + const blocks = []; + events.forEach((event) => { + const kind = event.kind || 'text'; + const roundId = event.roundId || ''; + const toolName = event.toolName || ''; + const last = blocks[blocks.length - 1]; + if ( + canMergeOutputEvent(event) + && last + && last.kind === kind + && last.roundId === roundId + && !last.isEnd + ) { + last.endCursor = event.cursor; + last.text = appendOutputText(last.text, event.text); + last.isEnd = Boolean(last.isEnd || event.isEnd); + last.eventCount += 1; + return; + } + blocks.push({ + startCursor: event.cursor, + endCursor: event.cursor, + kind, + roundId, + toolName, + toolState: event.toolState || '', + text: outputEventFallbackText(event), + isEnd: Boolean(event.isEnd), + eventCount: 1, + }); + }); + return blocks; +} + +function turnOutputBlockMatches(block) { + if (state.errorsOnly && block.toolState !== 'failed') return false; + if (!state.query) return true; + const haystack = [ + block.kind, + block.text, + block.toolName, + block.toolState, + block.roundId, + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + return haystack.includes(state.query); +} + +function cursorRangeLabel(block) { + return block.startCursor === block.endCursor + ? `#${block.startCursor}` + : `#${block.startCursor}-${block.endCursor}`; +} + +function turnOutputBlockRow(block) { + const row = document.createElement('li'); + row.className = 'log-row turn-output-row'; + row.dataset.kind = block.kind; + row.dataset.level = block.toolState === 'failed' ? 'error' : 'info'; + row.dataset.cursor = String(block.endCursor); + + const header = document.createElement('div'); + header.className = 'output-block__header'; + + const level = document.createElement('span'); + level.className = 'event-level'; + level.dataset.level = block.toolState === 'failed' ? 'error' : 'info'; + level.textContent = outputKindLabel(block.kind); + + const source = document.createElement('span'); + source.className = 'output-block__source'; + source.textContent = block.toolName || 'agent'; + + const cursor = document.createElement('span'); + cursor.className = 'output-block__cursor'; + cursor.textContent = cursorRangeLabel(block); + + header.append(level, source, cursor); + + if (block.roundId) { + const round = document.createElement('span'); + round.className = 'output-block__meta'; + round.textContent = shortId(block.roundId); + round.title = block.roundId; + header.append(round); + } + if (block.toolState) { + const status = document.createElement('span'); + status.className = 'output-block__meta'; + status.textContent = block.toolState; + header.append(status); + } + if (block.eventCount > 1) { + const chunks = document.createElement('span'); + chunks.className = 'output-block__meta'; + chunks.textContent = text('outputChunks', { value: block.eventCount }); + header.append(chunks); + } + + const message = document.createElement('div'); + message.className = 'output-block__message'; + message.textContent = block.text || outputKindLabel(block.kind); + + row.append(header, message); + return row; +} + +function renderTurnOutput() { + const task = runningOutputTask(); + if (task) ensureTurnOutputTarget(task); + const blocks = compactTurnOutputBlocks(state.turnOutput.events); + const matches = blocks.filter(turnOutputBlockMatches); + const visible = matches.slice(-MAX_RENDERED_EVENTS); + const fragment = document.createDocumentFragment(); + visible.forEach((block) => fragment.append(turnOutputBlockRow(block))); + view.logList.replaceChildren(fragment); + const hasOutput = visible.length !== 0; + view.logEmpty.hidden = hasOutput; + if (!hasOutput) { + const message = state.turnOutput.message + || (task ? text('noLiveOutput') : text('outputNotRunning')); + view.logEmpty.querySelector('p').textContent = message; + } + if (state.followLogs) { + requestAnimationFrame(() => { + view.logScroll.scrollTop = view.logScroll.scrollHeight; + view.newEvents.hidden = true; + }); + } else if (visible.length) { + view.newEvents.hidden = false; + } + if (task && !state.turnOutput.inFlight && !state.turnOutput.timer) { + scheduleTurnOutputPoll(state.turnOutput.events.length ? 1200 : 0); + } +} + +function renderLogs() { + if (state.logMode === 'output') { + renderTurnOutput(); + return; + } + const wasFollowing = state.followLogs; + const matches = state.events.filter(eventMatches); + state.visibleEvents = matches; + const visible = matches.slice(-MAX_RENDERED_EVENTS); + const tasks = state.snapshot && Array.isArray(state.snapshot.tasks) ? state.snapshot.tasks : []; + const liveTask = state.selectedTaskId ? null : primaryProgressTask(tasks); + const counts = progressCounts(tasks); + const fragment = document.createDocumentFragment(); + visible.forEach((event) => fragment.append(eventRow(event))); + if (liveTask) fragment.append(liveProgressRow(liveTask, counts)); + view.logList.replaceChildren(fragment); + view.logEmpty.querySelector('p').textContent = activeWorkEmptyMessage(); + view.logEmpty.hidden = visible.length !== 0 || Boolean(liveTask); + if (wasFollowing) { + requestAnimationFrame(() => { + view.logScroll.scrollTop = view.logScroll.scrollHeight; + view.newEvents.hidden = true; + }); + } else if (visible.length) { + view.newEvents.hidden = false; + } +} + +function renderAll() { + renderExecutionSupport(); + renderEnvironment(); + renderTasks(); + renderLiveness(); + renderLogs(); +} + +async function hydrateTaskMetadata(taskId) { + const task = taskForId(taskId); + const item = task && task.identity && task.identity.item; + if (!item || identityDescriptionOf(task)) return; + const metadataKey = itemKey(item); + if (state.metadataRequests.has(metadataKey)) return; + state.metadataRequests.add(metadataKey); + renderLiveness(); + try { + const response = await app.loopx.resolveIntake({ + input: itemUrl(item), + modelId: task.modelId || 'auto', + }); + const candidates = response && response.preview && Array.isArray(response.preview.candidates) + ? response.preview.candidates + : []; + const candidate = candidates.find((entry) => itemKey(entry.key) === metadataKey); + state.itemMetadata.set(metadataKey, { + title: candidate && candidate.title ? candidate.title : '', + description: candidate && candidate.description ? candidate.description : '', + unavailable: !candidate, + }); + } catch (_error) { + state.itemMetadata.set(metadataKey, { unavailable: true }); + } finally { + state.metadataRequests.delete(metadataKey); + renderTasks(); + renderLiveness(); + } +} + +function selectTask(taskId) { + state.selectedTaskId = taskId || null; + if (taskId) view.issueDescriptionPanel.open = false; + const task = taskId ? taskForId(taskId) : null; + if (task && task.state === 'running') { + setLogMode('output'); + } else if (!taskId && state.logMode === 'output') { + setLogMode('full'); + } else if (state.logMode === 'output') { + setLogMode('key'); + } + renderTasks(); + renderLiveness(); + renderLogs(); + if (taskId) void hydrateTaskMetadata(taskId); +} + +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 : []; + 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); + + updateCreateButton(); +} + +function renderIntakeWarnings() { + const preview = state.preview; + if (!preview) return; + const candidates = Array.isArray(preview.candidates) ? preview.candidates : []; + const selectedCount = selectedPreviewItems().length; + const warnings = []; + if (selectedCount > 1) warnings.push(text('batchSelection', { value: selectedCount })); + 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(preview.model.detail || text('modelUnavailable')); + } + if (preview.workspace && preview.workspace.disposition === 'unavailable') { + warnings.push(preview.workspace.detail || text('workspaceUnavailable')); + } + view.intakeWarning.hidden = warnings.length === 0; + view.intakeWarning.textContent = warnings.join(' '); +} + +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 syncSelectAll() { + const selectAll = view.candidateSelectAll; + if (!selectAll) return; + const enabled = [...view.candidateList.querySelectorAll('input[name="candidate"]:not(:disabled)')]; + const checked = [...view.candidateList.querySelectorAll('input[name="candidate"]:checked')]; + selectAll.checked = enabled.length > 0 && checked.length === enabled.length; + selectAll.indeterminate = checked.length > 0 && checked.length < enabled.length; +} + +function updateCreateButton() { + const itemCount = selectedPreviewItems().length; + const candidateCount = state.preview && Array.isArray(state.preview.candidates) + ? state.preview.candidates.length + : 0; + 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'); + view.candidateCount.textContent = text('selectedCandidates', { + selected: itemCount, + total: candidateCount, + }); + syncSelectAll(); + renderIntakeWarnings(); +} + +async function loadModelCatalog() { + const select = view.modelSelect; + if (!select || select.tagName !== 'SELECT') return; + if (!app || !app.loopx || typeof app.loopx.listModels !== 'function') return; + if (state.modelCatalogLoading) return; + if (state.modelCatalogLoaded && select.options.length > 1) return; + state.modelCatalogLoading = true; + select.dataset.loading = 'true'; + const loadingAutoOption = [...select.options].find((option) => option.value === 'auto'); + if (!state.modelCatalogLoaded && loadingAutoOption) { + loadingAutoOption.textContent = text('modelLoading'); + } + try { + const models = await app.loopx.listModels(); + const current = currentModelSelection(); + select.replaceChildren(); + const auto = document.createElement('option'); + auto.value = 'auto'; + auto.textContent = text('modelAuto'); + auto.selected = current === 'auto'; + select.appendChild(auto); + let selectedExists = current === '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; + selectedExists = selectedExists || option.selected; + select.appendChild(option); + } + if (!selectedExists) select.value = 'auto'; + state.modelCatalogLoaded = select.options.length > 1; + select.title = text('modelReloadTitle'); + } catch (error) { + const current = currentModelSelection(); + if (select.options.length === 0) { + const auto = document.createElement('option'); + auto.value = 'auto'; + auto.textContent = text('modelAuto'); + select.appendChild(auto); + } else { + const auto = [...select.options].find((option) => option.value === 'auto'); + if (auto) auto.textContent = text('modelAuto'); + } + select.value = current === 'auto' ? 'auto' : select.value; + select.title = errorMessage(error); + state.modelCatalogLoaded = false; + } finally { + state.modelCatalogLoading = false; + delete select.dataset.loading; + } +} + +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'); +} + +function summarizeOutcomes(outcomes) { + const createdCount = outcomes.filter((outcome) => outcome.kind === 'created').length; + const messages = []; + if (createdCount === 1) messages.push(text('taskCreated')); + if (createdCount > 1) messages.push(text('tasksCreated', { value: createdCount })); + + const grouped = new Map(); + outcomes + .filter((outcome) => outcome.kind !== 'created') + .forEach((outcome) => { + const message = outcomeMessage(outcome); + grouped.set(message, (grouped.get(message) || 0) + 1); + }); + grouped.forEach((count, message) => { + messages.push(count === 1 ? message : text('outcomeCount', { message, value: count })); + }); + return messages; +} + +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 = summarizeOutcomes(outcomes); + 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); + selectTask(null); + setLogMode('full'); + } 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); +} + +function openResetLoopxDialog() { + const tasks = state.snapshot && Array.isArray(state.snapshot.tasks) + ? state.snapshot.tasks.length + : 0; + view.resetLoopxMessage.textContent = text('resetLoopxMessage', { + tasks, + events: state.events.length, + }); + view.resetLoopxDialog.showModal(); +} + +async function resetLoopx() { + if (!state.snapshot || state.resetPending) return; + state.resetPending = true; + setButtonBusy(view.resetLoopx, true); + view.resetLoopxConfirm.disabled = true; + try { + const clientRequestId = requestId(); + await attachSnapshot(false); + let request = { + action: 'reset_all', + clientRequestId, + expectedRevision: Number((state.snapshot && state.snapshot.revision) || 0), + }; + let response = await app.loopx.action(request); + for (let attempt = 0; attempt < 2 && response && response.status === 'revision_conflict'; attempt += 1) { + await attachSnapshot(false); + const nextRevision = Number(response.currentRevision || (state.snapshot && state.snapshot.revision) || 0); + if (!Number.isSafeInteger(nextRevision) || nextRevision === request.expectedRevision) break; + request = { ...request, expectedRevision: nextRevision }; + response = await app.loopx.action(request); + } + if (response && response.status === 'revision_conflict') { + showNotice(response.message || text('revisionConflict'), 'error'); + } else if (response && response.status === 'rejected') { + showNotice(response.message || text('actionRejected'), 'error'); + } else { + state.events = []; + state.eventKeys.clear(); + state.selectedTaskId = null; + state.repositoryResumeTarget = null; + showNotice(text('resetLoopxApplied'), 'success'); + } + view.resetLoopxDialog.close(); + await attachSnapshot(false); + } catch (error) { + showNotice(errorMessage(error), 'error'); + view.resetLoopxDialog.close(); + await attachSnapshot(false); + } finally { + state.resetPending = false; + setButtonBusy(view.resetLoopx, false); + view.resetLoopxConfirm.disabled = false; + } +} + +function openRepositoryResumeDialog() { + const target = state.repositoryResumeTarget; + if (!target || !target.tasks.length) return; + view.repositoryResumeMessage.textContent = text('resumeRepositoryMessage', { + repository: repositoryLabel(target.repository), + value: target.tasks.length, + }); + view.repositoryResumeDialog.showModal(); +} + +async function resumeRepository() { + const target = state.repositoryResumeTarget; + if (!target || !target.tasks.length || !state.snapshot) { + view.repositoryResumeDialog.close(); + return; + } + const count = target.tasks.length; + state.repositoryResumePending = true; + renderRepositoryActions(state.snapshot.tasks || []); + view.repositoryResumeConfirm.disabled = true; + try { + const response = await app.loopx.action({ + action: 'resume_repository', + repository: target.repository, + clientRequestId: requestId(), + expectedRevision: Number(state.snapshot.revision || 0), + }); + if (response && response.status === 'revision_conflict') { + showNotice(response.message || text('revisionConflict'), 'error'); + } else if (response && response.status === 'rejected') { + showNotice(response.message || text('actionRejected'), 'error'); + } else { + showNotice(text('resumeRepositoryApplied', { value: count }), 'success'); + } + view.repositoryResumeDialog.close(); + await attachSnapshot(false); + } catch (error) { + showNotice(errorMessage(error), 'error'); + await attachSnapshot(false); + } finally { + state.repositoryResumePending = false; + view.repositoryResumeConfirm.disabled = false; + const tasks = state.snapshot && Array.isArray(state.snapshot.tasks) + ? state.snapshot.tasks + : []; + renderRepositoryActions(tasks); + } +} + +function mergeActionTask(response) { + if (!response || !response.task || !state.snapshot) return; + 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( + Number(state.snapshot.revision || 0), + Number(response.currentRevision || 0), + Number(response.task.revision || 0), + ); +} + +function latestActionRevision(response, fallback) { + const taskRevision = Number(response && response.task && response.task.revision); + if (Number.isSafeInteger(taskRevision)) return taskRevision; + const currentRevision = Number(response && response.currentRevision); + return Number.isSafeInteger(currentRevision) ? currentRevision : fallback; +} + +async function sendActionRequest(request) { + const response = await app.loopx.action(request); + mergeActionTask(response); + return response; +} + +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 } : {}), + }; + if (task && task.taskId) { + state.taskActionPending.set(task.taskId, action); + renderTasks(); + renderLiveness(); + } + try { + let response = await sendActionRequest(request); + if ( + response + && response.status === 'revision_conflict' + && task + && response.task + && response.task.taskId === task.taskId + ) { + const nextRevision = latestActionRevision(response, request.expectedRevision); + if (nextRevision !== request.expectedRevision) { + response = await sendActionRequest({ + ...request, + expectedRevision: nextRevision, + }); + } + } + 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; + } finally { + if (task && task.taskId && state.taskActionPending.get(task.taskId) === action) { + state.taskActionPending.delete(task.taskId); + renderTasks(); + renderLiveness(); + } + } +} + +function openGateDialog(task) { + const gate = latestGate(task.taskId); + if (!gate) { + showNotice(text('noGate'), 'error'); + return; + } + state.gate = { task, gateId: gate.gateId }; + view.gateTitle.textContent = identityTitleOf(task) || 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(), + turnOutput: state.logMode === 'output' ? state.turnOutput.events : [], + }; + 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'; + const fullMode = mode === 'full'; + const outputMode = mode === 'output'; + view.modeKey.classList.toggle('is-active', keyMode); + view.modeKey.setAttribute('aria-checked', String(keyMode)); + view.modeFull.classList.toggle('is-active', fullMode); + view.modeFull.setAttribute('aria-checked', String(fullMode)); + view.modeOutput.classList.toggle('is-active', outputMode); + view.modeOutput.setAttribute('aria-checked', String(outputMode)); + if (!outputMode) clearTurnOutputTimer(); + renderLogs(); + if (outputMode) scheduleTurnOutputPoll(0); +} + +const RAIL_MIN_WIDTH = 180; +const RAIL_MAX_WIDTH = 520; +const RAIL_DEFAULT_WIDTH = 286; +const RAIL_WIDTH_STORAGE_KEY = 'loopx.railWidth'; + +function setRailWidth(width) { + const workbench = view.taskRail.parentElement; + workbench.style.setProperty('--rail-width', `${width}px`); + state.railWidth = width; +} + +function bindRailSplitter() { + const splitter = view.railSplitter; + if (!splitter) return; + let startX = 0; + let startWidth = 0; + let active = false; + const width = () => state.railWidth || RAIL_DEFAULT_WIDTH; + + const move = (event) => { + if (!active) return; + const next = startWidth + (event.clientX - startX); + setRailWidth(Math.min(RAIL_MAX_WIDTH, Math.max(RAIL_MIN_WIDTH, next))); + event.preventDefault(); + }; + + const end = () => { + if (!active) return; + active = false; + splitter.classList.remove('is-dragging'); + splitter.classList.remove('is-focused'); + document.removeEventListener('pointermove', move); + document.removeEventListener('pointerup', end); + document.body.style.userSelect = ''; + splitter.setAttribute('aria-valuenow', String(width())); + persistRailWidth(); + }; + + splitter.addEventListener('pointerdown', (event) => { + if (state.railCollapsed) return; + active = true; + startX = event.clientX; + startWidth = width(); + splitter.classList.add('is-dragging'); + document.body.style.userSelect = 'none'; + document.addEventListener('pointermove', move); + document.addEventListener('pointerup', end); + event.preventDefault(); + }); + + splitter.addEventListener('focus', () => splitter.classList.add('is-focused')); + splitter.addEventListener('blur', () => splitter.classList.remove('is-focused')); + + // Double-click or double-activate resets to the default width. + splitter.addEventListener('dblclick', () => { + if (state.railCollapsed) return; + setRailWidth(RAIL_DEFAULT_WIDTH); + persistRailWidth(); + }); + splitter.addEventListener('keydown', (event) => { + if (state.railCollapsed) return; + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); + const step = event.shiftKey ? 48 : 12; + const next = event.key === 'ArrowRight' ? width() + step : width() - step; + setRailWidth(Math.min(RAIL_MAX_WIDTH, Math.max(RAIL_MIN_WIDTH, next))); + persistRailWidth(); + }); + + splitter.setAttribute('aria-valuemin', String(RAIL_MIN_WIDTH)); + splitter.setAttribute('aria-valuemax', String(RAIL_MAX_WIDTH)); + splitter.setAttribute('aria-valuenow', String(width())); + splitter.setAttribute('aria-controls', 'task-rail'); + + // Restore persisted width (session-only when storage is unavailable). + try { + const stored = window.localStorage.getItem(RAIL_WIDTH_STORAGE_KEY); + const parsed = stored === null ? NaN : Number(stored); + if (Number.isFinite(parsed) && parsed >= RAIL_MIN_WIDTH && parsed <= RAIL_MAX_WIDTH) { + setRailWidth(parsed); + } + } catch { + /* storage unavailable: keep default width for this session */ + } +} + +function persistRailWidth() { + const value = String(state.railWidth || RAIL_DEFAULT_WIDTH); + try { + window.localStorage.setItem(RAIL_WIDTH_STORAGE_KEY, value); + } catch { + /* storage unavailable: keep width for this session only */ + } +} + +function bindEvents() { + bindRailSplitter(); + view.modelSelect.addEventListener('pointerdown', () => void loadModelCatalog()); + view.modelSelect.addEventListener('focus', () => void loadModelCatalog()); + view.modelSelect.addEventListener('change', () => { + writeStoredModelSelection(view.modelSelect.value || 'auto'); + if (state.preview) { + state.preview = null; + if (view.intakeDialog.open) view.intakeDialog.close(); + showNotice(text('modelSelectionChanged')); + } + }); + view.intakeForm.addEventListener('submit', (event) => { + event.preventDefault(); + void resolveIntake(); + }); + view.candidateSelectAll.addEventListener('change', () => { + const checked = view.candidateSelectAll.checked; + view.candidateList.querySelectorAll('input[name="candidate"]:not(:disabled)').forEach((input) => { + input.checked = checked; + }); + updateCreateButton(); + }); + view.resetLoopx.addEventListener('click', openResetLoopxDialog); + view.resetLoopxCancel.addEventListener('click', () => { + view.resetLoopxDialog.close(); + }); + view.resetLoopxConfirm.addEventListener('click', (event) => { + event.preventDefault(); + void resetLoopx(); + }); + view.syncButton.addEventListener('click', () => void attachSnapshot(false)); + view.retryEnvironment.addEventListener('click', async () => { + await performAction('retry_environment', null); + }); + view.resumeRepository.addEventListener('click', openRepositoryResumeDialog); + view.repositoryResumeCancel.addEventListener('click', () => { + view.repositoryResumeDialog.close(); + }); + view.repositoryResumeConfirm.addEventListener('click', (event) => { + event.preventDefault(); + void resumeRepository(); + }); + 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.showAllEvents.addEventListener('click', () => selectTask(null)); + view.modeKey.addEventListener('click', () => setLogMode('key')); + view.modeFull.addEventListener('click', () => setLogMode('full')); + view.modeOutput.addEventListener('click', () => setLogMode('output')); + 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', () => { + clearTurnOutputTimer(); + if (app.loopx && typeof app.loopx.offEvent === 'function') { + app.loopx.offEvent(onLoopxEvent); + } + }); + window.setInterval(updateLivenessClock, 5000); + 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..31ed70bd2b --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/loopx/policy.rs @@ -0,0 +1,478 @@ +//! 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::Aborted + | LoopxTaskState::RecoveryRequired + | LoopxTaskState::Failed + ), + LoopxTaskState::Stopped | LoopxTaskState::Failed => { + matches!(next, LoopxTaskState::Queued | LoopxTaskState::Archived) + } + LoopxTaskState::Aborted => matches!(next, 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 { + match state { + LoopxTaskState::Preparing | LoopxTaskState::RetryWait => LoopxRestartDecision::Preserve { + state: LoopxTaskState::Queued, + }, + state if state.was_executing_at_shutdown() => LoopxRestartDecision::RequireRecovery, + state => 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..c832ab78b1 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/loopx/ports.rs @@ -0,0 +1,810 @@ +//! Narrow service boundary for the pinned LoopX CLI adapter. + +use super::types::{ + LoopxEventCursor, LoopxIntakeCandidate, LoopxIntakeTarget, LoopxIssueKey, + LoopxPermissionScope, LoopxRepositoryKey, LoopxTurnOutputEvent, +}; +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, + /// Public title already resolved by the host intake adapter. The LoopX + /// process receives this as inline metadata and must not refetch it. + pub title: String, +} + +#[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 LoopxCliUserGate { + pub gate_id: String, + pub message: String, + pub action_kind: Option, +} + +#[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 pending_user_gate: Option, + 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 LoopxWorkspaceProbeRequest { + pub operation_id: String, + /// When present, also verifies that Git can read the canonical repository. + pub repository: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxWorkspaceProbeResult { + pub git_version: Option, + pub workspace_root: String, + pub repository_verified: bool, +} + +#[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, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxWorkspaceDisposeRequest { + 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 LoopxWorkspaceDisposeResult { + /// The worktree (and, when it was the last reference, the shared bare + /// repository) was removed from disk. + pub removed: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxWorkspaceResetRequest { + pub operation_id: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxWorkspaceResetResult { + /// The managed LoopX workspace root was removed from disk. + pub removed: bool, +} + +/// Creates or reuses an isolated worktree for exactly one canonical item. +pub trait LoopxWorkspacePort: Send + Sync { + fn probe( + &self, + request: LoopxWorkspaceProbeRequest, + ) -> LoopxHostFuture<'_, LoopxWorkspaceProbeResult>; + + fn prepare( + &self, + request: LoopxWorkspacePrepareRequest, + ) -> LoopxHostFuture<'_, LoopxWorkspacePrepareResult>; + + fn verify( + &self, + request: LoopxWorkspaceVerifyRequest, + ) -> LoopxHostFuture<'_, LoopxWorkspaceVerifyResult>; + + fn cancel( + &self, + request: LoopxWorkspaceCancelRequest, + ) -> LoopxHostFuture<'_, LoopxWorkspaceCancelResult>; + + /// Removes the task worktree after terminal settlement. With the shared + /// bare-repository layout this also removes the bare repo once its last + /// worktree is gone. The caller must guarantee the task is terminal. + fn dispose( + &self, + request: LoopxWorkspaceDisposeRequest, + ) -> LoopxHostFuture<'_, LoopxWorkspaceDisposeResult>; + + /// Removes every managed LoopX workspace and goal registry under the + /// service-owned root. This is reserved for explicit full-reset actions. + fn reset(&self, request: LoopxWorkspaceResetRequest) + -> LoopxHostFuture<'_, LoopxWorkspaceResetResult>; +} + +#[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 LoopxAgentProbeRequest { + pub operation_id: String, + /// `None`, `auto`, and `primary` all validate the configured primary model. + pub model_id: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAgentProbeResult { + pub model_id: String, + pub supports_images: bool, +} + +#[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, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAgentResetRequest { + pub operation_id: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAgentResetResult { + pub removed_runtime_event_logs: u32, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAgentOutputSinceRequest { + pub operation_id: String, + pub session_id: String, + pub turn_id: String, + pub stream_id: Option, + pub after_cursor: LoopxEventCursor, + pub limit: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAgentOutputSinceResult { + pub stream_id: Option, + pub events: Vec, + pub next_cursor: LoopxEventCursor, + pub has_more: bool, +} + +/// Starts fresh transient Agent sessions bound to the prepared worktree. +pub trait LoopxAgentPort: Send + Sync { + fn probe(&self, request: LoopxAgentProbeRequest) -> LoopxHostFuture<'_, LoopxAgentProbeResult>; + + 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>; + + /// Removes LoopX-owned transient Agent diagnostics after every active + /// session has been cancelled and discarded. + fn reset(&self, request: LoopxAgentResetRequest) -> LoopxHostFuture<'_, LoopxAgentResetResult>; + + /// Reads the in-flight Agent turn output projection. Completed turns may + /// already have handed their data back to session persistence and discarded + /// this transient projection. + fn output_since( + &self, + request: LoopxAgentOutputSinceRequest, + ) -> LoopxHostFuture<'_, LoopxAgentOutputSinceResult>; +} 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..0481048a45 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/loopx/types.rs @@ -0,0 +1,686 @@ +//! 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.5.1"; +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, + /// Bounded plain-text excerpt of the issue/PR body, kept for task + /// surfaces. The projection intentionally never retains the full remote + /// body (only this trimmed excerpt) to bound snapshot size. + pub description: 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, + pub detail: Option, +} + +#[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, + pub detail: Option, +} + +#[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, + Aborted, + Completed, + Failed, + Archived, + #[default] + #[serde(other)] + RecoveryRequired, +} + +impl LoopxTaskState { + pub fn is_terminal(self) -> bool { + matches!( + self, + Self::Stopped | Self::Aborted | Self::Completed | Self::Failed | Self::Archived + ) + } + + pub fn was_executing_at_shutdown(self) -> bool { + matches!(self, Self::Running | 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, + /// Issue / PR title captured at task creation, so task surfaces can show + /// content instead of only the item number. Empty for legacy records. + pub title: String, + /// Bounded plain-text excerpt of the issue/PR description captured at + /// task creation. Empty for legacy records. + pub description: String, +} + +#[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, + Abort, + Resume, + ResumeRepository, + ResetAll, + 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 repository: 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, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxTurnOutputStatus { + #[default] + Current, + TaskNotFound, + NotRunning, + StaleTurn, + OutputUnavailable, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxTurnOutputEventKind { + #[default] + Text, + Thinking, + ModelRoundStarted, + ModelRoundCompleted, + Tool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxTurnOutputEvent { + pub cursor: LoopxEventCursor, + pub turn_id: String, + pub round_id: Option, + pub kind: LoopxTurnOutputEventKind, + pub text: Option, + pub tool_name: Option, + pub tool_state: Option, + pub is_end: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxTurnOutputSinceRequest { + pub task_id: String, + pub turn_id: Option, + pub stream_id: Option, + pub after_cursor: LoopxEventCursor, + pub limit: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxTurnOutputSinceResponse { + pub status: LoopxTurnOutputStatus, + pub task_id: String, + pub turn_id: Option, + pub stream_id: Option, + pub events: Vec, + pub next_cursor: LoopxEventCursor, + pub has_more: bool, + pub message: Option, +} + +#[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..be296d6924 --- /dev/null +++ b/src/crates/contracts/product-domains/tests/loopx_contracts.rs @@ -0,0 +1,534 @@ +#![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, LoopxCliGoalSnapshot, + LoopxCliHandshakeRequest, LoopxCliPort, LoopxCoreEnvironmentFacts, LoopxCreateTaskRequest, + LoopxDedupDecision, LoopxEnvironmentFact, LoopxEnvironmentFactStatus, LoopxEnvironmentStatus, + LoopxEventsPageStatus, LoopxExistingTask, LoopxIntakeCandidate, LoopxIntakeParseErrorKind, + LoopxIntakePreview, 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, + ..Default::default() + }, + 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_requeues_safe_pending_work_and_recovers_inflight_work() { + for state in [LoopxTaskState::Preparing, LoopxTaskState::RetryWait] { + assert_eq!( + decide_task_restart(state), + LoopxRestartDecision::Preserve { + state: LoopxTaskState::Queued + } + ); + assert_eq!(task_state_after_restart(state), LoopxTaskState::Queued); + } + + for state in [LoopxTaskState::Running, LoopxTaskState::Cancelling] { + assert_eq!( + decide_task_restart(state), + LoopxRestartDecision::RequireRecovery + ); + assert_eq!( + task_state_after_restart(state), + LoopxTaskState::RecoveryRequired + ); + } + + for state in [ + LoopxTaskState::Queued, + LoopxTaskState::WaitingForUser, + 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"); + + let preview: LoopxIntakePreview = serde_json::from_value(serde_json::json!({ + "fingerprint": "legacy-preview", + "workspace": { + "disposition": "clone_required", + "repositoryVerified": false + }, + "model": { + "modelId": "auto", + "available": true, + "supportsImages": false + } + })) + .unwrap(); + assert_eq!(preview.workspace.detail, None); + assert_eq!(preview.model.detail, None); + + let goal: LoopxCliGoalSnapshot = serde_json::from_value(serde_json::json!({ + "goalId": "legacy-goal", + "state": "waiting_for_user", + "runDecision": "waiting_for_user" + })) + .unwrap(); + assert_eq!(goal.pending_user_gate, None); +} + +#[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 reset: LoopxActionRequest = serde_json::from_value(serde_json::json!({ + "action": "reset_all", + "clientRequestId": "request-reset", + "expectedRevision": 18 + })) + .unwrap(); + assert_eq!(reset.action, LoopxActionKind::ResetAll); + assert_eq!(reset.task_id, None); + + 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..6b634ec01a 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -195,6 +195,29 @@ miniapp-runtime = [ "uuid", "which", ] +miniapp-loopx = [ + "async-trait", + "bitfun-product-domains/miniapp", + "bitfun-services-core/process-runtime", + "dep:bitfun-product-domains", + "dunce", + "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 +437,7 @@ product-full = [ "function-agents", "git", "hook-import", + "miniapp-loopx", "miniapp-runtime", "mcp", "plugin-source", @@ -471,6 +495,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..3c303b0f5f --- /dev/null +++ b/src/crates/services/services-integrations/src/miniapp/loopx_cli.rs @@ -0,0 +1,2381 @@ +//! 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.5.1"; +pub const LOOPX_PINNED_VERSION_TAG: &str = "v0.5.1"; +pub const LOOPX_PINNED_VERSION_OUTPUT: &str = "loopx 0.5.1"; +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, + stdout_tail: Vec, + 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(), + stdout_tail: output_tail(&stdout_capture.bytes), + 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.5.1".to_string() + } + LoopxCommandSource::FixedSystemCommand => { + "fixed-system-loopx-v0.5.1".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?; + let mut snapshot = + project_goal_snapshot(&request.goal_id, &output.payload, operation_id)?; + if snapshot.run_decision == loopx_contract::LoopxCliRunDecision::WaitingForUser { + let todos = run_port_command( + self, + &request.context, + list_todos_args(&request.goal_id), + &observer, + ) + .await?; + snapshot.pending_user_gate = + Some(project_pending_user_gate(&todos.payload, operation_id)?); + } + Ok(snapshot) + }) + } + + 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 { + let kind = match request.item.kind { + loopx_contract::LoopxItemKind::Issue => "issue", + loopx_contract::LoopxItemKind::PullRequest => "pull_request", + }; + let metadata = serde_json::json!({ + "number": request.item.number, + "state": "open", + "title": request.title, + "labels": [], + "kind": kind, + }) + .to_string(); + [ + "issue-fix".to_string(), + "workflow-plan".to_string(), + "--url".to_string(), + request.item.canonical_url(), + "--repo-path".to_string(), + request.context.worktree_path.clone(), + "--metadata-json".to_string(), + metadata, + ] + .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.5.1", + 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) + .or_else(|| envelope.get("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 { + 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), + pending_user_gate: None, + 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 project_pending_user_gate( + payload: &Value, + operation_id: &str, +) -> loopx_contract::LoopxCliResult { + require_payload_ok(payload, operation_id)?; + let todos = payload + .get("todos") + .and_then(Value::as_array) + .ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + "todo list response did not contain todos", + false, + ) + })?; + let gate = todos + .iter() + .find(|todo| { + let status = todo + .get("status") + .and_then(Value::as_str) + .unwrap_or_default(); + todo.get("role").and_then(Value::as_str) == Some("user") + && todo.get("task_class").and_then(Value::as_str) == Some("user_gate") + && todo.get("done").and_then(Value::as_bool) != Some(true) + && !matches!( + status, + "completed" | "closed" | "done" | "archived" | "cancelled" + ) + }) + .ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + "LoopX requested a user decision without an open typed user gate", + false, + ) + })?; + Ok(loopx_contract::LoopxCliUserGate { + gate_id: required_json_string(gate, "todo_id", operation_id)?, + message: truncate_message(&required_json_string(gate, "text", operation_id)?), + action_kind: gate + .get("action_kind") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string), + }) +} + +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.5.1 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), + }; + let message = match &error { + LoopxCliAdapterError::Process(LoopxProcessError::Exited { + code, + stdout_tail, + stderr_tail, + }) if !stderr_tail.is_empty() || !stdout_tail.is_empty() => { + let details = if stderr_tail.is_empty() { + stdout_tail + } else { + stderr_tail + }; + format!( + "LoopX process exited with status {code:?}: {}", + process_error_detail(details) + ) + } + _ => error.to_string(), + }; + port_error(kind, operation_id, message, 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 output_tail(bytes: &[u8]) -> Vec { + let lines = String::from_utf8_lossy(bytes) + .lines() + .rev() + .take(20) + .map(str::to_string) + .collect::>(); + lines.into_iter().rev().collect() +} + +fn process_error_detail(lines: &[String]) -> String { + let detail = lines + .iter() + .rev() + .find(|line| line.contains("\"error\"")) + .cloned() + .unwrap_or_else(|| lines.join(" | ")); + truncate_message(&detail) +} + +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..5337e1712d --- /dev/null +++ b/src/crates/services/services-integrations/src/miniapp/loopx_github.rs @@ -0,0 +1,1123 @@ +use super::loopx_cli::LoopxIntakeMetadataProvider; +use async_trait::async_trait; +use bitfun_product_domains::miniapp::loopx as loopx_contract; +use reqwest::header::{HeaderMap, ETAG, IF_NONE_MATCH, RETRY_AFTER}; +use reqwest::{Client, StatusCode}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeSet, HashMap}; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tokio::sync::Mutex; + +const GITHUB_API_ROOT: &str = "https://api.github.com"; +const INTAKE_PAGE_SIZE: usize = 100; +const INTAKE_MAX_REPOSITORY_PAGES: usize = 10; +const AUTHENTICATED_CREDENTIAL_TTL: Duration = Duration::from_secs(300); +const MISSING_CREDENTIAL_TTL: Duration = Duration::from_secs(30); +const RESPONSE_CACHE_TTL: Duration = Duration::from_secs(60); +const RATE_SNAPSHOT_TTL: Duration = Duration::from_secs(30); +const MAX_RESPONSE_CACHE_ENTRIES: usize = 256; +const DEFAULT_SECONDARY_BACKOFF: Duration = Duration::from_secs(60); +/// Upper bound of the plain-text excerpt kept per issue/PR body. Keeps task +/// snapshots (and the event stream) bounded while still giving task surfaces +/// readable context. +const DESCRIPTION_EXCERPT_MAX: usize = 600; + +/// Trims a GitHub markdown body into a bounded plain-text excerpt used by +/// task surfaces. Markdown image/link syntax is resolved to its visible text, +/// common markup characters are folded away, and the result is whitespace- +/// normalized. The full body is never projected into the candidate/task +/// snapshot. +fn candidate_description(body: &str) -> String { + let mut excerpt = String::with_capacity(body.len().min(DESCRIPTION_EXCERPT_MAX + 64)); + let mut chars = body.chars().peekable(); + while let Some(ch) = chars.next() { + if excerpt.len() >= DESCRIPTION_EXCERPT_MAX { + break; + } + match ch { + '!' if chars.peek() == Some(&'[') => { + // `![alt](url)` → keep alt, drop url. + chars.next(); // '[' + let alt = take_until(&mut chars, ']'); + if chars.peek() == Some(&'(') { + take_until(&mut chars, ')'); + } + push_word(&mut excerpt, &alt); + } + '[' => { + // `[text](url)` → keep text, drop url. + let text = take_until(&mut chars, ']'); + if chars.peek() == Some(&'(') { + take_until(&mut chars, ')'); + } + push_word(&mut excerpt, &text); + } + '#' | '*' | '_' | '`' | '>' | '~' => push_word(&mut excerpt, ""), + _ => excerpt.push(ch), + } + } + excerpt = excerpt.split_whitespace().collect::>().join(" "); + if excerpt.len() > DESCRIPTION_EXCERPT_MAX { + let mut boundary = DESCRIPTION_EXCERPT_MAX; + while boundary > 0 && !excerpt.is_char_boundary(boundary) { + boundary -= 1; + } + excerpt.truncate(boundary); + excerpt.push('…'); + } + excerpt +} + +/// Reads characters up to and including `stop`, returning the text before it. +fn take_until(chars: &mut std::iter::Peekable>, stop: char) -> String { + let mut text = String::new(); + while let Some(next) = chars.next() { + if next == stop { + break; + } + text.push(next); + } + text +} + +/// Appends `word` to `excerpt`, separated by a space when non-empty. +fn push_word(excerpt: &mut String, word: &str) { + let word = word.trim(); + if word.is_empty() { + return; + } + if !excerpt.is_empty() && !excerpt.ends_with(' ') { + excerpt.push(' '); + } + excerpt.push_str(word); +} + +#[derive(Clone)] +struct GithubCredential { + token: Option, + source: Option<&'static str>, + detail: String, +} + +impl GithubCredential { + fn cache_scope(&self) -> String { + let Some(token) = self.token.as_deref() else { + return "anonymous".to_string(); + }; + let digest = Sha256::digest(token.as_bytes()); + let fingerprint = digest[..12] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + format!("authenticated:{fingerprint}") + } +} + +#[derive(Clone)] +struct CachedCredential { + credential: GithubCredential, + checked_at: Instant, +} + +#[derive(Clone)] +struct CachedGithubResponse { + value: Value, + etag: Option, + stored_at: Instant, +} + +#[derive(Debug, Clone)] +struct RateLimitSnapshot { + credential_scope: String, + limit: Option, + remaining: Option, + reset_at: Option, + observed_at: Instant, +} + +#[derive(Debug, Clone)] +struct RateLimitBlock { + credential_scope: String, + until: Instant, + message: String, +} + +#[derive(Debug, Default)] +struct GithubRateState { + snapshot: Option, + block: Option, +} + +#[derive(Clone)] +pub struct GithubLoopxIntakeMetadataProvider { + client: Client, + credential: Arc>>, + responses: Arc>>, + rate: Arc>, + request_gate: Arc>, +} + +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, + credential: Arc::new(Mutex::new(None)), + responses: Arc::new(Mutex::new(HashMap::new())), + rate: Arc::new(Mutex::new(GithubRateState::default())), + request_gate: Arc::new(Mutex::new(())), + }) + } + + async fn github_credential(&self, force_refresh: bool) -> GithubCredential { + let mut cached = self.credential.lock().await; + if !force_refresh { + if let Some(existing) = cached.as_ref() { + let ttl = if existing.credential.token.is_some() { + AUTHENTICATED_CREDENTIAL_TTL + } else { + MISSING_CREDENTIAL_TTL + }; + if existing.checked_at.elapsed() < ttl { + return existing.credential.clone(); + } + } + } + let credential = load_github_credential().await; + *cached = Some(CachedCredential { + credential: credential.clone(), + checked_at: Instant::now(), + }); + credential + } + + async fn invalidate_credential(&self) { + *self.credential.lock().await = None; + } + + async fn active_rate_limit(&self, credential_scope: &str) -> Option { + let mut rate = self.rate.lock().await; + match rate.block.as_ref() { + Some(block) if block.credential_scope != credential_scope => { + rate.block = None; + None + } + Some(block) if block.until > Instant::now() => Some(block.clone()), + Some(_) => { + rate.block = None; + None + } + None => None, + } + } + + async fn recent_rate_snapshot(&self, credential_scope: &str) -> Option { + self.rate.lock().await.snapshot.clone().filter(|snapshot| { + snapshot.credential_scope == credential_scope + && snapshot.observed_at.elapsed() < RATE_SNAPSHOT_TTL + }) + } + + async fn store_rate_snapshot( + &self, + credential_scope: &str, + limit: Option, + remaining: Option, + reset_at: Option, + ) { + self.rate.lock().await.snapshot = Some(RateLimitSnapshot { + credential_scope: credential_scope.to_string(), + limit, + remaining, + reset_at, + observed_at: Instant::now(), + }); + } + + async fn record_rate_headers( + &self, + status: StatusCode, + headers: &HeaderMap, + credential_scope: &str, + ) -> Option { + let now = Instant::now(); + let unix_now = now_unix_seconds(); + let limit = header_u64(headers, "x-ratelimit-limit"); + let remaining = header_u64(headers, "x-ratelimit-remaining"); + let reset_at = header_u64(headers, "x-ratelimit-reset"); + let retry_after = header_u64(headers, RETRY_AFTER.as_str()); + let mut rate = self.rate.lock().await; + if limit.is_some() || remaining.is_some() || reset_at.is_some() { + rate.snapshot = Some(RateLimitSnapshot { + credential_scope: credential_scope.to_string(), + limit, + remaining, + reset_at, + observed_at: now, + }); + } + let block = if remaining == Some(0) { + let wait_seconds = reset_at + .map(|reset| reset.saturating_sub(unix_now).max(1)) + .unwrap_or(60); + Some(RateLimitBlock { + credential_scope: credential_scope.to_string(), + until: now + Duration::from_secs(wait_seconds), + message: format!( + "GitHub primary API rate limit is exhausted; retry in {wait_seconds} seconds{}", + reset_at + .map(|reset| format!(" (reset epoch {reset})")) + .unwrap_or_default() + ), + }) + } else if status == StatusCode::TOO_MANY_REQUESTS || retry_after.is_some() { + let wait_seconds = retry_after + .unwrap_or(DEFAULT_SECONDARY_BACKOFF.as_secs()) + .max(1); + Some(RateLimitBlock { + credential_scope: credential_scope.to_string(), + until: now + Duration::from_secs(wait_seconds), + message: format!( + "GitHub secondary API rate limit is active; retry in {wait_seconds} seconds" + ), + }) + } else { + None + }; + if let Some(block) = block.clone() { + rate.block = Some(block); + } else if status.is_success() { + rate.block = None; + } + block + } + + async fn record_secondary_limit(&self, credential_scope: &str) -> RateLimitBlock { + let block = RateLimitBlock { + credential_scope: credential_scope.to_string(), + until: Instant::now() + DEFAULT_SECONDARY_BACKOFF, + message: format!( + "GitHub secondary API rate limit is active; retry in {} seconds", + DEFAULT_SECONDARY_BACKOFF.as_secs() + ), + }; + self.rate.lock().await.block = Some(block.clone()); + block + } + + async fn cached_response( + &self, + credential_scope: &str, + path: &str, + ) -> Option { + self.responses + .lock() + .await + .get(&response_cache_key(credential_scope, path)) + .cloned() + } + + async fn store_response( + &self, + credential_scope: &str, + path: &str, + value: Value, + etag: Option, + ) { + let cache_key = response_cache_key(credential_scope, path); + let mut responses = self.responses.lock().await; + if responses.len() >= MAX_RESPONSE_CACHE_ENTRIES && !responses.contains_key(&cache_key) { + if let Some(oldest) = responses + .iter() + .min_by_key(|(_, response)| response.stored_at) + .map(|(path, _)| path.clone()) + { + responses.remove(&oldest); + } + } + responses.insert( + cache_key, + CachedGithubResponse { + value, + etag, + stored_at: Instant::now(), + }, + ); + } + + async fn get_json( + &self, + path: &str, + deadline: Duration, + operation_id: &str, + ) -> loopx_contract::LoopxCliResult { + let _request = self.request_gate.lock().await; + let credential = self.github_credential(false).await; + let credential_scope = credential.cache_scope(); + let cached = self.cached_response(&credential_scope, path).await; + if let Some(cached) = cached.as_ref() { + if cached.stored_at.elapsed() < RESPONSE_CACHE_TTL { + return Ok(cached.value.clone()); + } + } + if let Some(block) = self.active_rate_limit(&credential_scope).await { + return Err(rate_limit_error(operation_id, block)); + } + let mut request = self + .client + .get(format!("{GITHUB_API_ROOT}{path}")) + .timeout(deadline); + if let Some(token) = credential.token.as_deref() { + request = request.bearer_auth(token); + } + if credential.token.is_some() { + if let Some(etag) = cached.as_ref().and_then(|cached| cached.etag.as_deref()) { + request = request.header(IF_NONE_MATCH, etag); + } + } + let response = request.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(); + let headers = response.headers().clone(); + if let Some(block) = self + .record_rate_headers(status, &headers, &credential_scope) + .await + { + return Err(rate_limit_error(operation_id, block)); + } + if status == StatusCode::NOT_MODIFIED { + return cached.map(|cached| cached.value).ok_or_else(|| { + loopx_contract::LoopxCliError::new( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + "GitHub returned 304 without a cached response", + ) + .for_operation(operation_id) + }); + } + if status == StatusCode::UNAUTHORIZED { + self.invalidate_credential().await; + return Err(loopx_contract::LoopxCliError::new( + loopx_contract::LoopxCliErrorKind::Backend, + "GitHub authentication is invalid or expired; run `gh auth login --hostname github.com --web`", + ) + .for_operation(operation_id)); + } + 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 body = response.text().await.unwrap_or_default(); + let api_detail = github_error_detail(&body); + let lower_detail = api_detail.to_ascii_lowercase(); + if status == StatusCode::FORBIDDEN + && (lower_detail.contains("secondary rate limit") + || lower_detail.contains("abuse detection")) + { + let block = self.record_secondary_limit(&credential_scope).await; + return Err(rate_limit_error(operation_id, block)); + } + let message = if status == StatusCode::FORBIDDEN { + if credential.token.is_none() { + format!( + "GitHub request was forbidden without authentication; {}", + credential.detail + ) + } else { + format!( + "GitHub request was forbidden (403); the repository may be private or the credential lacks access{}", + optional_api_detail(&api_detail) + ) + } + } else { + format!( + "GitHub intake request returned HTTP {status}{}", + optional_api_detail(&api_detail) + ) + }; + return Err(loopx_contract::LoopxCliError::new( + loopx_contract::LoopxCliErrorKind::Backend, + message, + ) + .for_operation(operation_id) + .retryable(status.is_server_error())); + } + let etag = headers + .get(ETAG) + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let value = 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) + })?; + self.store_response(&credential_scope, path, value.clone(), etag) + .await; + Ok(value) + } + + async fn resolve_repository_candidates( + &self, + repository: &loopx_contract::LoopxRepositoryKey, + deadline: Duration, + operation_id: &str, + ) -> loopx_contract::LoopxCliResult<(Vec, bool)> { + let mut candidates = Vec::new(); + let mut seen = BTreeSet::new(); + for page in 1..=INTAKE_MAX_REPOSITORY_PAGES { + let value = self + .get_json( + &format!( + "/repos/{}/{}/issues?state=open&sort=updated&direction=desc&per_page={INTAKE_PAGE_SIZE}&page={page}", + 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) + })?; + append_repository_issue_candidates(repository, rows, &mut seen, &mut candidates); + if rows.len() < INTAKE_PAGE_SIZE { + return Ok((candidates, false)); + } + } + Ok((candidates, true)) + } +} + +async fn load_github_credential() -> GithubCredential { + if let Some((name, token)) = ["GH_TOKEN", "GITHUB_TOKEN"].into_iter().find_map(|name| { + std::env::var(name) + .ok() + .map(|token| token.trim().to_string()) + .filter(|token| !token.is_empty()) + .map(|token| (name, token)) + }) { + return GithubCredential { + token: Some(token), + source: Some("environment"), + detail: format!("GitHub authentication is configured through {name}"), + }; + } + let Some(executable) = resolve_github_cli() else { + return GithubCredential { + token: None, + source: None, + detail: "GitHub CLI was not found and GH_TOKEN/GITHUB_TOKEN is not set".to_string(), + }; + }; + let output = match tokio::process::Command::new(executable) + .args(["auth", "token", "--hostname", "github.com"]) + .output() + .await + { + Ok(output) => output, + Err(error) => { + return GithubCredential { + token: None, + source: None, + detail: format!("GitHub CLI credential lookup failed: {error}"), + }; + } + }; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let detail = stderr + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .map(str::trim) + .unwrap_or("run `gh auth login --hostname github.com --web`"); + return GithubCredential { + token: None, + source: None, + detail: format!("GitHub CLI is not authenticated: {detail}"), + }; + } + let token = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if token.is_empty() { + GithubCredential { + token: None, + source: None, + detail: "GitHub CLI returned an empty credential".to_string(), + } + } else { + GithubCredential { + token: Some(token), + source: Some("GitHub CLI"), + detail: "GitHub authentication is provided by the local GitHub CLI login".to_string(), + } + } +} + +fn header_u64(headers: &HeaderMap, name: &str) -> Option { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse().ok()) +} + +fn response_cache_key(credential_scope: &str, path: &str) -> String { + format!("{credential_scope}\n{path}") +} + +fn rate_limit_error(operation_id: &str, block: RateLimitBlock) -> loopx_contract::LoopxCliError { + loopx_contract::LoopxCliError::new(loopx_contract::LoopxCliErrorKind::Backend, block.message) + .for_operation(operation_id) + .retryable(false) +} + +fn github_error_detail(body: &str) -> String { + let detail = serde_json::from_str::(body) + .ok() + .and_then(|value| { + value + .get("message") + .and_then(Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| body.trim().to_string()); + detail.chars().take(300).collect() +} + +fn optional_api_detail(detail: &str) -> String { + if detail.is_empty() { + String::new() + } else { + format!(": {detail}") + } +} + +fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn resolve_github_cli() -> Option { + if let Ok(path) = which::which("gh") { + return Some(path); + } + github_cli_candidates() + .into_iter() + .find(|path| path.is_file()) +} + +fn github_cli_candidates() -> Vec { + #[cfg(windows)] + { + let mut candidates = Vec::new(); + for variable in ["ProgramFiles", "ProgramFiles(x86)"] { + if let Some(root) = std::env::var_os(variable) { + candidates.push(PathBuf::from(root).join("GitHub CLI").join("gh.exe")); + } + } + if let Some(root) = std::env::var_os("LOCALAPPDATA") { + candidates.push( + PathBuf::from(root) + .join("Programs") + .join("GitHub CLI") + .join("gh.exe"), + ); + } + candidates + } + #[cfg(not(windows))] + { + Vec::new() + } +} + +#[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 (resolved_candidates, was_truncated) = self + .resolve_repository_candidates(&repository, deadline, operation_id) + .await?; + candidates = resolved_candidates; + truncated = was_truncated; + } + } + 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 _request = self.request_gate.lock().await; + let credential = self.github_credential(false).await; + let credential_scope = credential.cache_scope(); + if let Some(block) = self.active_rate_limit(&credential_scope).await { + let snapshot = self.recent_rate_snapshot(&credential_scope).await; + return Ok(build_github_auth_probe( + &credential, + snapshot.as_ref(), + Some(block.message), + )); + } + if let Some(snapshot) = self.recent_rate_snapshot(&credential_scope).await { + return Ok(build_github_auth_probe(&credential, Some(&snapshot), None)); + } + let mut request = self + .client + .get(format!("{GITHUB_API_ROOT}/rate_limit")) + .timeout(deadline); + if let Some(token) = credential.token.as_deref() { + request = request.bearer_auth(token); + } + let response = request.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(); + let headers = response.headers().clone(); + let header_block = self + .record_rate_headers(status, &headers, &credential_scope) + .await; + if status == StatusCode::UNAUTHORIZED { + self.invalidate_credential().await; + return Ok(loopx_contract::LoopxGithubAuthProbe { + authenticated: false, + detail: Some( + "GitHub authentication is invalid or expired; run `gh auth login --hostname github.com --web`" + .to_string(), + ), + ..loopx_contract::LoopxGithubAuthProbe::default() + }); + } + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + let api_detail = github_error_detail(&body); + let lower_detail = api_detail.to_ascii_lowercase(); + let block = if let Some(block) = header_block { + Some(block) + } else if status == StatusCode::FORBIDDEN + && (lower_detail.contains("secondary rate limit") + || lower_detail.contains("abuse detection")) + { + Some(self.record_secondary_limit(&credential_scope).await) + } else { + None + }; + return Ok(loopx_contract::LoopxGithubAuthProbe { + authenticated: false, + detail: Some(block.map(|block| block.message).unwrap_or_else(|| { + format!( + "GitHub auth probe returned HTTP {status}{}; {}", + optional_api_detail(&api_detail), + credential.detail + ) + })), + ..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 reset_at = parsed + .as_ref() + .and_then(|value| value.pointer("/rate/reset")) + .and_then(Value::as_u64); + self.store_rate_snapshot(&credential_scope, limit, remaining, reset_at) + .await; + let snapshot = RateLimitSnapshot { + credential_scope, + limit, + remaining, + reset_at, + observed_at: Instant::now(), + }; + Ok(build_github_auth_probe( + &credential, + Some(&snapshot), + header_block.map(|block| block.message), + )) + } +} + +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(), + description: candidate_description(body), + 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 +} + +fn build_github_auth_probe( + credential: &GithubCredential, + snapshot: Option<&RateLimitSnapshot>, + override_detail: Option, +) -> loopx_contract::LoopxGithubAuthProbe { + let authenticated = credential.token.is_some(); + let remaining = snapshot.and_then(|snapshot| snapshot.remaining); + let detail = override_detail.unwrap_or_else(|| match (authenticated, remaining) { + (true, Some(count)) => format!( + "Authenticated GitHub access via {} ({count} requests remaining this hour)", + credential.source.unwrap_or("configured credential") + ), + (true, None) => format!( + "Authenticated GitHub access via {}", + credential.source.unwrap_or("configured credential") + ), + (false, Some(count)) => format!( + "Unauthenticated GitHub access ({count} of {} requests remaining this hour); {}", + snapshot.and_then(|snapshot| snapshot.limit).unwrap_or(60), + credential.detail + ), + (false, None) => credential.detail.clone(), + }); + let detail = if remaining == Some(0) { + let reset = snapshot + .and_then(|snapshot| snapshot.reset_at) + .map(|reset| format!("; reset epoch {reset}")) + .unwrap_or_default(); + format!("{detail}{reset}") + } else { + detail + }; + loopx_contract::LoopxGithubAuthProbe { + authenticated, + rate_limit_remaining: remaining, + detail: Some(detail), + } +} + +fn append_repository_issue_candidates( + repository: &loopx_contract::LoopxRepositoryKey, + rows: &[Value], + seen: &mut BTreeSet, + candidates: &mut Vec, +) { + 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; + }; + if !seen.insert(number) { + continue; + } + candidates.push(candidate_from_value( + loopx_contract::LoopxIssueKey { + repository: repository.clone(), + kind: loopx_contract::LoopxItemKind::Issue, + number, + }, + value, + true, + )); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use reqwest::header::HeaderValue; + + #[test] + fn metadata_projection_keeps_bounded_plain_text_excerpt_not_full_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) and a [link](https://example.test/x)" + }); + let candidate = candidate_from_value(key, &value, false); + assert_eq!(candidate.title, "Visible title"); + assert!(candidate.has_images); + assert!(candidate.description.contains("private-looking")); + assert!(candidate.description.contains("image")); + // Full body and remote URLs never leak into the projection. + let serialized = serde_json::to_string(&candidate).expect("serialize"); + assert!(!serialized.contains("https://example.test")); + assert!(candidate.description.len() <= DESCRIPTION_EXCERPT_MAX); + } + + #[test] + fn repository_candidate_collection_filters_pull_requests_and_duplicates() { + let repository = loopx_contract::LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "owner".to_string(), + repository: "repo".to_string(), + }; + let rows = vec![ + serde_json::json!({ + "number": 7, + "title": "First issue", + "state": "open", + "body": "" + }), + serde_json::json!({ + "number": 8, + "title": "Pull request", + "state": "open", + "pull_request": {} + }), + serde_json::json!({ + "number": 7, + "title": "Duplicate issue", + "state": "open", + "body": "" + }), + ]; + let mut seen = BTreeSet::new(); + let mut candidates = Vec::new(); + + append_repository_issue_candidates(&repository, &rows, &mut seen, &mut candidates); + + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].key.number, 7); + assert!(candidates[0].from_repository); + assert!(!candidates[0].default_selected); + } + + #[tokio::test] + async fn primary_rate_limit_blocks_local_retries_until_reset() { + let provider = GithubLoopxIntakeMetadataProvider::new().unwrap(); + let mut headers = HeaderMap::new(); + headers.insert("x-ratelimit-limit", HeaderValue::from_static("5000")); + headers.insert("x-ratelimit-remaining", HeaderValue::from_static("0")); + headers.insert( + "x-ratelimit-reset", + HeaderValue::from_str(&(now_unix_seconds() + 120).to_string()).unwrap(), + ); + + let block = provider + .record_rate_headers(StatusCode::FORBIDDEN, &headers, "authenticated:account-a") + .await + .expect("primary limit should create a local block"); + assert!(block.message.contains("primary API rate limit")); + assert!(provider + .active_rate_limit("authenticated:account-a") + .await + .is_some()); + assert!(provider.active_rate_limit("anonymous").await.is_none()); + let error = rate_limit_error("rate-limited", block); + assert!(!error.retryable); + assert_eq!(error.operation_id.as_deref(), Some("rate-limited")); + + let snapshot = provider + .recent_rate_snapshot("authenticated:account-a") + .await + .unwrap(); + assert_eq!(snapshot.limit, Some(5000)); + assert_eq!(snapshot.remaining, Some(0)); + } + + #[tokio::test] + async fn retry_after_creates_a_secondary_rate_limit_block() { + let provider = GithubLoopxIntakeMetadataProvider::new().unwrap(); + let mut headers = HeaderMap::new(); + headers.insert(RETRY_AFTER, HeaderValue::from_static("90")); + + let block = provider + .record_rate_headers(StatusCode::FORBIDDEN, &headers, "authenticated:account-a") + .await + .expect("retry-after should create a local block"); + assert!(block.message.contains("secondary API rate limit")); + assert!(block.message.contains("90 seconds")); + } + + #[tokio::test] + async fn response_cache_keeps_value_and_etag_for_conditional_requests() { + let provider = GithubLoopxIntakeMetadataProvider::new().unwrap(); + let value = serde_json::json!({"number": 42, "state": "open"}); + provider + .store_response( + "authenticated:account-a", + "/repos/owner/repo/issues/42", + value.clone(), + Some("\"etag-42\"".to_string()), + ) + .await; + + let cached = provider + .cached_response("authenticated:account-a", "/repos/owner/repo/issues/42") + .await + .unwrap(); + assert_eq!(cached.value, value); + assert_eq!(cached.etag.as_deref(), Some("\"etag-42\"")); + assert!(cached.stored_at.elapsed() < RESPONSE_CACHE_TTL); + assert!(provider + .cached_response("authenticated:account-b", "/repos/owner/repo/issues/42") + .await + .is_none()); + } + + #[test] + fn auth_probe_reports_credential_source_and_anonymous_diagnostics() { + let snapshot = RateLimitSnapshot { + credential_scope: "authenticated:account-a".to_string(), + limit: Some(5000), + remaining: Some(4999), + reset_at: None, + observed_at: Instant::now(), + }; + let authenticated = build_github_auth_probe( + &GithubCredential { + token: Some("secret-not-rendered".to_string()), + source: Some("GitHub CLI"), + detail: String::new(), + }, + Some(&snapshot), + None, + ); + assert!(authenticated.authenticated); + assert!(authenticated + .detail + .as_deref() + .is_some_and(|detail| detail.contains("via GitHub CLI"))); + assert!(!authenticated + .detail + .as_deref() + .unwrap_or_default() + .contains("secret-not-rendered")); + + let anonymous = build_github_auth_probe( + &GithubCredential { + token: None, + source: None, + detail: "GitHub CLI is not authenticated".to_string(), + }, + Some(&RateLimitSnapshot { + credential_scope: "anonymous".to_string(), + limit: Some(60), + remaining: Some(12), + reset_at: None, + observed_at: Instant::now(), + }), + None, + ); + assert!(!anonymous.authenticated); + assert!(anonymous + .detail + .as_deref() + .is_some_and(|detail| detail.contains("12 of 60"))); + } +} 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..27b93ed356 --- /dev/null +++ b/src/crates/services/services-integrations/src/miniapp/loopx_workspace.rs @@ -0,0 +1,1321 @@ +//! 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::atomic::{AtomicU64, Ordering}; +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"; +static WORKSPACE_PROBE_SEQUENCE: AtomicU64 = AtomicU64::new(1); + +fn git_compatible_path(path: &Path) -> PathBuf { + dunce::simplified(path).to_path_buf() +} + +#[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, + /// Shared bare repository for this repository. All tasks of one repository + /// share a single object database via `git worktree add`; it is removed + /// once the last worktree is disposed. + pub bare_repo_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 repository_dir = root_dir.join(&repository_hash); + let worktree_path = repository_dir.join(&task_hash); + Ok(LoopxWorkspaceLayout { + repository_identity, + repository_hash, + task_hash: task_hash.clone(), + bare_repo_path: repository_dir.join("bare.git"), + 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("--config"), + // The target repository may contain paths beyond the Windows + // 260-character limit once joined with the workspace root; keep + // git capable of long paths inside every created repository. + OsString::from("core.longpaths=true"), + 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, + } +} + +/// Shared bare clone for one repository. All tasks of the repository add +/// linked worktrees from this single object database. +pub fn plan_git_bare_clone_command( + git_executable: &Path, + layout: &LoopxWorkspaceLayout, +) -> LoopxGitCommandPlan { + LoopxGitCommandPlan { + executable: git_executable.to_path_buf(), + args: vec![ + OsString::from("clone"), + OsString::from("--bare"), + OsString::from("--config"), + // Long-path support must travel with the shared bare repository so + // linked worktree checkouts can write files whose joined path + // exceeds the Windows 260-character limit. + OsString::from("core.longpaths=true"), + OsString::from("--origin"), + OsString::from("origin"), + OsString::from("--"), + OsString::from(&layout.clone_url), + layout.bare_repo_path.as_os_str().to_owned(), + ], + environment: git_noninteractive_environment(), + current_dir: None, + } +} + +/// Adds a linked worktree from the shared bare repository, checking out the +/// task branch. Equivalent to the old per-task full clone + branch checkout +/// but shares the object database across all tasks of the repository. +pub fn plan_git_worktree_add_command( + git_executable: &Path, + layout: &LoopxWorkspaceLayout, +) -> LoopxGitCommandPlan { + LoopxGitCommandPlan { + executable: git_executable.to_path_buf(), + args: vec![ + OsString::from("-C"), + layout.bare_repo_path.as_os_str().to_owned(), + OsString::from("worktree"), + OsString::from("add"), + OsString::from("-b"), + OsString::from(&layout.branch_name), + layout.worktree_path.as_os_str().to_owned(), + ], + environment: git_noninteractive_environment(), + current_dir: None, + } +} + +/// Removes a task worktree (and its registration) from the shared bare repo. +pub fn plan_git_worktree_remove_command( + git_executable: &Path, + layout: &LoopxWorkspaceLayout, +) -> LoopxGitCommandPlan { + LoopxGitCommandPlan { + executable: git_executable.to_path_buf(), + args: vec![ + OsString::from("-C"), + layout.bare_repo_path.as_os_str().to_owned(), + OsString::from("worktree"), + OsString::from("remove"), + OsString::from("--force"), + layout.worktree_path.as_os_str().to_owned(), + ], + environment: git_noninteractive_environment(), + current_dir: None, + } +} + +/// Porcelain worktree list of the shared bare repo. Used after dispose to +/// decide whether the last worktree is gone and the bare repo can be deleted. +pub fn plan_git_worktree_list_command( + git_executable: &Path, + layout: &LoopxWorkspaceLayout, +) -> LoopxGitCommandPlan { + LoopxGitCommandPlan { + executable: git_executable.to_path_buf(), + args: vec![ + OsString::from("-C"), + layout.bare_repo_path.as_os_str().to_owned(), + OsString::from("worktree"), + OsString::from("list"), + OsString::from("--porcelain"), + ], + 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 probe_inner( + &self, + request: &loopx_contract::LoopxWorkspaceProbeRequest, + cancellation: CancellationToken, + ) -> loopx_contract::LoopxHostResult { + if request.operation_id.trim().is_empty() { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::InvalidInput, + &request.operation_id, + "operation_id is required", + false, + )); + } + 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, + ) + })?; + verify_workspace_root_writable(&canonical_root, &request.operation_id).await?; + + let version = self + .run_git( + &request.operation_id, + git_version_plan(&self.config.git_executable), + self.config.command_deadline, + cancellation.clone(), + ) + .await?; + let git_version = version.stdout.trim().to_string(); + + let repository_verified = if let Some(repository) = &request.repository { + validate_repository(&request.operation_id, repository)?; + self.run_git( + &request.operation_id, + git_repository_probe_plan(&self.config.git_executable, repository), + self.config.command_deadline, + cancellation, + ) + .await?; + true + } else { + false + }; + + Ok(loopx_contract::LoopxWorkspaceProbeResult { + git_version: (!git_version.is_empty()).then_some(git_version), + workspace_root: git_compatible_path(&canonical_root) + .to_string_lossy() + .into_owned(), + repository_verified, + }) + } + + 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( + &git_compatible_path(&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)); + } + + // Shared-object-database layout: ensure the bare repository exists once + // per repository, then add a linked worktree for this task. Older + // per-task full clones on disk keep working (they take the reuse path + // above); only brand-new workspaces get the shared bare repo. + if !tokio::fs::try_exists(&layout.bare_repo_path) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + format!("failed to probe shared bare repository: {error}"), + true, + ) + })? + { + self.run_git( + &request.operation_id, + plan_git_bare_clone_command(&self.config.git_executable, &layout), + self.config.clone_deadline, + cancellation.clone(), + ) + .await?; + } + self.run_git( + &request.operation_id, + plan_git_worktree_add_command(&self.config.git_executable, &layout), + self.config.clone_deadline, + cancellation.clone(), + ) + .await?; + self.verify_remote(&layout, &request.operation_id, cancellation.clone()) + .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( + &git_compatible_path(&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(), + }, + )) + } + + /// Removes the task worktree after terminal settlement, then removes the + /// shared bare repository once its last linked worktree is gone. + /// + /// - Shared layout: `git worktree remove --force` (linked worktree), then + /// `git worktree list --porcelain`; when only the bare repository itself + /// remains, the bare directory is removed too. + /// - Legacy layout (per-task full clone, `.git` is a directory): the whole + /// worktree directory is removed directly. Upgraded installs keep their + /// existing clones working; only new workspaces use the shared layout. + async fn dispose_inner( + &self, + request: &loopx_contract::LoopxWorkspaceDisposeRequest, + cancellation: CancellationToken, + ) -> loopx_contract::LoopxHostResult { + validate_task_and_item(&request.operation_id, &request.task_id, &request.item)?; + 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( + &git_compatible_path(&canonical_root), + &request.task_id, + &request.item, + )?; + let bare_exists = tokio::fs::try_exists(&layout.bare_repo_path) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + format!("failed to probe bare repository: {error}"), + true, + ) + })?; + + 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?; + // Only BitFun-owned workspaces may be removed. A missing or + // mismatched marker keeps the directory (and any user changes). + let marker = read_workspace_marker(&layout, &request.operation_id).await?; + validate_marker(&marker, &layout, &request.operation_id)?; + + let dot_git = layout.worktree_path.join(".git"); + let linked_worktree = tokio::fs::metadata(&dot_git) + .await + .map(|metadata| metadata.is_file()) + .unwrap_or(false); + + if linked_worktree && bare_exists { + self.run_git( + &request.operation_id, + plan_git_worktree_remove_command(&self.config.git_executable, &layout), + self.config.command_deadline, + cancellation.clone(), + ) + .await?; + } else { + tokio::fs::remove_dir_all(&layout.worktree_path) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + format!("failed to remove task worktree: {error}"), + false, + ) + })?; + } + } + + // Remove the shared bare repository once no linked worktree remains. + if bare_exists { + let listing = self + .run_git( + &request.operation_id, + plan_git_worktree_list_command(&self.config.git_executable, &layout), + self.config.command_deadline, + cancellation, + ) + .await?; + let worktree_entries = listing + .stdout + .lines() + .filter(|line| line.starts_with("worktree ")) + .count(); + if worktree_entries <= 1 { + tokio::fs::remove_dir_all(&layout.bare_repo_path) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + format!("failed to remove shared bare repository: {error}"), + false, + ) + })?; + } + } + Ok(loopx_contract::LoopxWorkspaceDisposeResult { removed: true }) + } + + async fn reset_inner( + &self, + request: &loopx_contract::LoopxWorkspaceResetRequest, + ) -> loopx_contract::LoopxHostResult { + if request.operation_id.trim().is_empty() { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::InvalidInput, + &request.operation_id, + "operation_id is required", + false, + )); + } + let root_metadata = match tokio::fs::symlink_metadata(&self.config.root_dir).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(loopx_contract::LoopxWorkspaceResetResult { removed: false }); + } + Err(error) => { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + format!("failed to inspect LoopX workspace root: {error}"), + true, + )); + } + }; + if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::Conflict, + &request.operation_id, + "LoopX workspace reset refused to remove a non-directory or symlink root", + false, + )); + } + 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, + ) + })?; + if !canonical_root.is_absolute() || canonical_root.parent().is_none() { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::Conflict, + &request.operation_id, + "LoopX workspace reset refused an unsafe root path", + false, + )); + } + let root_name = canonical_root + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + if !root_name.eq_ignore_ascii_case("workspaces") + && !root_name.eq_ignore_ascii_case("loopx-workspaces") + { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::Conflict, + &request.operation_id, + "LoopX workspace reset refused an unexpected root directory name", + false, + )); + } + tokio::fs::remove_dir_all(&canonical_root) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + format!("failed to remove LoopX workspace root: {error}"), + false, + ) + })?; + Ok(loopx_contract::LoopxWorkspaceResetResult { removed: true }) + } +} + +impl loopx_contract::LoopxWorkspacePort for LoopxWorkspaceService { + fn probe( + &self, + request: loopx_contract::LoopxWorkspaceProbeRequest, + ) -> loopx_contract::LoopxHostFuture<'_, loopx_contract::LoopxWorkspaceProbeResult> { + Box::pin(async move { + let (cancellation, _registration) = self.register_operation(&request.operation_id)?; + self.probe_inner(&request, cancellation).await + }) + } + + 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, + }) + }) + } + + fn dispose( + &self, + request: loopx_contract::LoopxWorkspaceDisposeRequest, + ) -> loopx_contract::LoopxHostFuture<'_, loopx_contract::LoopxWorkspaceDisposeResult> { + Box::pin(async move { + let (cancellation, _registration) = self.register_operation(&request.operation_id)?; + let _mutation = self.mutation_lock.lock().await; + self.dispose_inner(&request, cancellation).await + }) + } + + fn reset( + &self, + request: loopx_contract::LoopxWorkspaceResetRequest, + ) -> loopx_contract::LoopxHostFuture<'_, loopx_contract::LoopxWorkspaceResetResult> { + Box::pin(async move { + let _mutation = self.mutation_lock.lock().await; + self.reset_inner(&request).await + }) + } +} + +#[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_version_plan(git: &Path) -> LoopxGitCommandPlan { + LoopxGitCommandPlan { + executable: git.to_path_buf(), + args: vec![OsString::from("--version")], + environment: BTreeMap::new(), + current_dir: None, + } +} + +fn git_repository_probe_plan( + git: &Path, + repository: &loopx_contract::LoopxRepositoryKey, +) -> LoopxGitCommandPlan { + LoopxGitCommandPlan { + executable: git.to_path_buf(), + args: vec![ + OsString::from("ls-remote"), + OsString::from("--exit-code"), + OsString::from("--"), + OsString::from(format!( + "https://github.com/{}/{}.git", + repository.owner, repository.repository + )), + OsString::from("HEAD"), + ], + environment: git_noninteractive_environment(), + current_dir: None, + } +} + +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, + } +} + +/// Resolves the real Git metadata directory for a worktree path. +/// +/// A standalone clone keeps `.git` as a directory; a linked worktree from a +/// shared bare repository keeps `.git` as a file containing +/// `gitdir: `. The ownership marker must live inside the real +/// gitdir so both layouts stay covered. +async fn resolve_git_dir( + worktree_path: &Path, + operation_id: &str, +) -> loopx_contract::LoopxHostResult { + let dot_git = worktree_path.join(".git"); + let metadata = tokio::fs::metadata(&dot_git).await.map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Conflict, + operation_id, + format!( + "existing workspace has no usable .git entry: {error}; existing data was preserved" + ), + false, + ) + })?; + if metadata.is_dir() { + return Ok(dot_git); + } + // Linked worktree: .git is a gitdir pointer file. + let pointer = tokio::fs::read_to_string(&dot_git).await.map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Conflict, + operation_id, + format!("failed to read worktree gitdir pointer: {error}"), + false, + ) + })?; + let gitdir = pointer + .lines() + .find_map(|line| line.strip_prefix("gitdir:").map(str::trim)) + .map(PathBuf::from) + .ok_or_else(|| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Conflict, + operation_id, + "worktree .git pointer has no gitdir entry", + false, + ) + })?; + Ok(gitdir) +} + +async fn read_workspace_marker( + layout: &LoopxWorkspaceLayout, + operation_id: &str, +) -> loopx_contract::LoopxHostResult { + let git_dir = resolve_git_dir(&layout.worktree_path, operation_id).await?; + let marker_path = git_dir.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 = resolve_git_dir(&layout.worktree_path, operation_id).await?; + 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, + )); + } + validate_repository(operation_id, &item.repository)?; + if item.number == 0 { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::InvalidInput, + operation_id, + "workspace preparation requires a canonical GitHub item", + false, + )); + } + Ok(()) +} + +fn validate_repository( + operation_id: &str, + repository: &loopx_contract::LoopxRepositoryKey, +) -> loopx_contract::LoopxHostResult<()> { + if !repository.host.eq_ignore_ascii_case("github.com") + || !is_github_slug(&repository.owner) + || !is_github_slug(&repository.repository) + { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::InvalidInput, + operation_id, + "workspace preparation requires a canonical GitHub repository", + false, + )); + } + Ok(()) +} + +async fn verify_workspace_root_writable( + canonical_root: &Path, + operation_id: &str, +) -> loopx_contract::LoopxHostResult<()> { + let probe_path = canonical_root.join(format!( + ".loopx-write-probe-{}-{}", + std::process::id(), + WORKSPACE_PROBE_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + let file = tokio::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&probe_path) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + operation_id, + format!("LoopX workspace root is not writable: {error}"), + false, + ) + })?; + drop(file); + tokio::fs::remove_file(&probe_path).await.map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + operation_id, + format!("failed to clean up workspace write probe: {error}"), + true, + ) + }) +} + +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_process_error(&error), + retryable, + ) +} + +fn format_workspace_process_error(error: &LoopxProcessError) -> String { + let stderr_tail: &[String] = match error { + LoopxProcessError::Exited { stderr_tail, .. } + | LoopxProcessError::Timeout { stderr_tail, .. } + | LoopxProcessError::Cancelled { stderr_tail } => stderr_tail, + _ => &[], + }; + let detail = stderr_tail + .iter() + .rev() + .find(|line| !line.trim().is_empty()) + .map(|line| line.trim()) + .unwrap_or_default(); + let summary = match error { + LoopxProcessError::Exited { code, .. } => { + format!("workspace Git command exited with status {code:?}") + } + LoopxProcessError::Start { message } => { + format!("workspace Git command could not start: {message}") + } + LoopxProcessError::Io { message } => { + format!("workspace Git command IO failed: {message}") + } + LoopxProcessError::Timeout { deadline_ms, .. } => { + format!("workspace Git command timed out after {deadline_ms} ms") + } + LoopxProcessError::Cancelled { .. } => "workspace Git command was cancelled".to_string(), + LoopxProcessError::OutputLimit { limit_bytes } => { + format!("workspace Git output exceeded {limit_bytes} bytes") + } + }; + if detail.is_empty() { + summary + } else { + format!("{summary}: {detail}") + } +} + +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..f944b23e5f --- /dev/null +++ b/src/crates/services/services-integrations/tests/miniapp_loopx_contracts.rs @@ -0,0 +1,1179 @@ +use async_trait::async_trait; +use bitfun_product_domains::miniapp::loopx::{ + LoopxCliCallContext, LoopxCliCreateGoalRequest, LoopxCliErrorKind, LoopxCliGoalContext, + LoopxCliHandshakeRequest, LoopxCliInspectGoalRequest, LoopxCliIntakePlan, + LoopxCliPlanItemRequest, LoopxCliPort, LoopxCliProgress, LoopxCliProgressSink, + LoopxCliRunDecision, LoopxCliSource, LoopxCliTodoPlan, LoopxIssueKey, LoopxItemKind, + LoopxPermissionScope, LoopxRepositoryKey, LoopxWorkspaceDisposeRequest, LoopxWorkspacePort, + LoopxWorkspacePrepareRequest, LoopxWorkspaceProbeRequest, +}; +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, + /// Directory paths of registered linked worktrees, used by `worktree list`. + worktrees: Mutex>, +} + +impl WorkspaceFakeRunner { + fn new(remote: &str) -> Self { + Self { + plans: Mutex::new(Vec::new()), + remote: Mutex::new(remote.to_string()), + worktrees: Mutex::new(Vec::new()), + } + } +} + +#[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")) { + // Shared layout: `git clone --bare `. + let target = PathBuf::from(plan.args.last().expect("clone target")); + std::fs::create_dir_all(target.join("objects")).unwrap(); + std::fs::create_dir_all(target.join("refs")).unwrap(); + } else if plan.args.windows(3).any(|w| { + w == [ + OsString::from("worktree"), + OsString::from("add"), + OsString::from("-b"), + ] + }) { + // `git -C worktree add -b `. + let worktree = PathBuf::from(plan.args.last().expect("worktree add path")); + let bare = PathBuf::from(&plan.args[1]); + std::fs::create_dir_all(&worktree).unwrap(); + // Real git puts a `gitdir: ` pointer file in the linked + // worktree; simulate that so the ownership marker lands in the + // bare repo's per-worktree gitdir. + let name = worktree + .file_name() + .expect("worktree name") + .to_string_lossy(); + let gitdir = bare.join("worktrees").join(name.as_ref()); + std::fs::create_dir_all(&gitdir).unwrap(); + std::fs::write( + worktree.join(".git"), + format!("gitdir: {}\n", gitdir.to_string_lossy()), + ) + .unwrap(); + std::fs::write(gitdir.join("bfx-linked"), b"ok").unwrap(); + self.worktrees.lock().unwrap().push(worktree.clone()); + } else if plan.args.windows(3).any(|w| { + w == [ + OsString::from("worktree"), + OsString::from("remove"), + OsString::from("--force"), + ] + }) { + let worktree = PathBuf::from(plan.args.last().expect("worktree remove path")); + let _ = std::fs::remove_dir_all(&worktree); + self.worktrees + .lock() + .unwrap() + .retain(|path| path != &worktree); + } else if plan + .args + .windows(2) + .any(|w| w == [OsString::from("worktree"), OsString::from("list")]) + { + // Porcelain output: first entry is the bare repository itself. + let mut stdout = format!( + "worktree {}\nHEAD 0000000000000000000000000000000000000000\n\n", + plan.args[1].to_string_lossy() + ); + for worktree in self.worktrees.lock().unwrap().iter() { + stdout.push_str(&format!( + "worktree {}\nHEAD 1111111111111111111111111111111111111111\n\n", + worktree.to_string_lossy() + )); + } + self.plans.lock().unwrap().push(plan); + return Ok(LoopxProcessOutput { + stdout, + stderr_tail: Vec::new(), + elapsed: Duration::from_millis(1), + }); + } + let stdout = if plan.args == [OsString::from("--version")] { + "git version 2.53.0\n".to_string() + } else 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.5.1", 1); + let runner = Arc::new(FakeRunner::with_results(handshake_results( + "loopx 0.5.1", + 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.5.1"); + 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.5.1", 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.5.1", 1); + let runner = Arc::new(FakeRunner::with_results(handshake_results( + "loopx 0.5.1", + "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.5.1", 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.5.1", 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, + title: "Issue with “UTF-8” title".to_string(), + }; + + 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("--metadata-json"), + OsString::from( + json!({ + "number": 42, + "state": "open", + "title": "Issue with “UTF-8” title", + "labels": [], + "kind": "issue", + }) + .to_string(), + ), + ] + ); +} + +#[tokio::test] +async fn item_plan_process_failure_preserves_the_stderr_cause() { + let temporary = tempfile::tempdir().unwrap(); + stage_bundle(temporary.path(), "v0.5.1", 1); + let worktree = temporary.path().join("worktree"); + std::fs::create_dir_all(&worktree).unwrap(); + let registry = worktree.join(".loopx").join("registry.json"); + let runner = Arc::new(FakeRunner::with_results( + handshake_results("loopx 0.5.1", LOOPX_COMMAND_REFERENCE_SCHEMA) + .into_iter() + .chain([Err(LoopxProcessError::Exited { + code: Some(1), + stdout_tail: vec![ + "irrelevant output before the error".repeat(30), + r#"{"ok":false,"error":"metadata projection failed"}"#.to_string(), + ], + stderr_tail: Vec::new(), + })]), + )); + let adapter = adapter_with_runner(temporary.path(), runner, Arc::new(FakeLocator::new(None))); + let error = adapter + .plan_item( + LoopxCliPlanItemRequest { + context: LoopxCliGoalContext { + call: LoopxCliCallContext { + operation_id: "plan-item-failure".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: LoopxIssueKey { + repository: LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "owner".to_string(), + repository: "repo".to_string(), + }, + kind: LoopxItemKind::Issue, + number: 42, + }, + title: "Issue with UTF-8 title".to_string(), + }, + &RecordingProgressSink::default(), + ) + .await + .unwrap_err(); + + assert_eq!(error.kind, LoopxCliErrorKind::Process); + assert!(error.message.contains("metadata projection failed")); + assert!(error.message.contains("status Some(1)")); +} + +#[tokio::test] +async fn waiting_goal_projects_the_concrete_open_user_gate() { + let temporary = tempfile::tempdir().unwrap(); + stage_bundle(temporary.path(), "v0.5.1", 1); + let worktree = temporary.path().join("worktree"); + std::fs::create_dir_all(&worktree).unwrap(); + let registry = worktree.join(".loopx").join("registry.json"); + let turn_plan = json!({ + "ok": true, + "schema_version": "loopx_turn_plan_v0", + "turn_envelope": { + "should_run": false, + "state": "operator_gate", + "open_count": 1, + "user": { + "action_required": true, + "open_count": 1 + }, + "action_signature": { + "source_decision_hash": "sha256:user-gate-revision" + } + } + }); + let todos = json!({ + "ok": true, + "todos": [{ + "todo_id": "todo_release_approval", + "role": "user", + "task_class": "user_gate", + "status": "open", + "done": false, + "text": "Approve creating the draft pull request", + "action_kind": "gate" + }] + }); + let runner = Arc::new(FakeRunner::with_results( + handshake_results("loopx 0.5.1", LOOPX_COMMAND_REFERENCE_SCHEMA) + .into_iter() + .chain([output(turn_plan.to_string()), output(todos.to_string())]), + )); + let adapter = adapter_with_runner( + temporary.path(), + runner.clone(), + Arc::new(FakeLocator::new(None)), + ); + + let snapshot = adapter + .inspect_goal( + LoopxCliInspectGoalRequest { + context: LoopxCliGoalContext { + call: LoopxCliCallContext { + operation_id: "inspect-user-gate".to_string(), + deadline_at: None, + }, + task_id: "task-42".to_string(), + generation: 3, + 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(), + }, + &RecordingProgressSink::default(), + ) + .await + .unwrap(); + + assert_eq!(snapshot.run_decision, LoopxCliRunDecision::WaitingForUser); + assert_eq!(snapshot.waiting_user_todo_count, 1); + let gate = snapshot.pending_user_gate.expect("projected user gate"); + assert_eq!(gate.gate_id, "todo_release_approval"); + assert_eq!(gate.message, "Approve creating the draft pull request"); + assert_eq!(gate.action_kind.as_deref(), Some("gate")); + let commands = runner + .plans() + .into_iter() + .skip(2) + .map(|plan| plan.args) + .collect::>(); + assert_eq!(commands.len(), 2); + assert!(commands[1] + .windows(2) + .any(|args| { args == [OsString::from("todo"), OsString::from("list")] })); +} + +#[tokio::test] +async fn ordinary_monitor_wait_does_not_require_a_user_gate() { + let temporary = tempfile::tempdir().unwrap(); + stage_bundle(temporary.path(), "v0.5.1", 1); + let worktree = temporary.path().join("worktree"); + std::fs::create_dir_all(&worktree).unwrap(); + let registry = worktree.join(".loopx").join("registry.json"); + let turn_plan = json!({ + "ok": true, + "schema_version": "loopx_turn_plan_v0", + "turn_envelope": { + "should_run": false, + "state": "waiting", + "action_required": false, + "user": { + "action_required": false, + "open_count": 0 + }, + "action_signature": { + "source_decision_hash": "sha256:monitor-wait-revision" + } + } + }); + let runner = Arc::new(FakeRunner::with_results( + handshake_results("loopx 0.5.1", LOOPX_COMMAND_REFERENCE_SCHEMA) + .into_iter() + .chain([output(turn_plan.to_string())]), + )); + let adapter = adapter_with_runner( + temporary.path(), + runner.clone(), + Arc::new(FakeLocator::new(None)), + ); + + let snapshot = adapter + .inspect_goal( + LoopxCliInspectGoalRequest { + context: LoopxCliGoalContext { + call: LoopxCliCallContext { + operation_id: "inspect-monitor-wait".to_string(), + deadline_at: None, + }, + task_id: "task-monitor".to_string(), + generation: 1, + worktree_path: worktree.to_string_lossy().into_owned(), + registry_path: registry.to_string_lossy().into_owned(), + }, + goal_id: "goal-monitor".to_string(), + agent_id: "bitfun-agent".to_string(), + }, + &RecordingProgressSink::default(), + ) + .await + .unwrap(); + + assert_eq!(snapshot.run_decision, LoopxCliRunDecision::Wait); + assert_eq!(snapshot.pending_user_gate, None); + assert_eq!(runner.plans().len(), 3); +} + +#[tokio::test] +async fn create_goal_recovery_does_not_duplicate_an_existing_planned_todo() { + let temporary = tempfile::tempdir().unwrap(); + stage_bundle(temporary.path(), "v0.5.1", 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.5.1", 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(); + // Shared layout: bare clone + worktree add + origin verify, then the + // reuse path verifies the origin again. + assert_eq!(plans.len(), 4); + assert_eq!(plans[0].deadline, Duration::from_secs(7)); + assert_eq!(plans[1].deadline, Duration::from_secs(7)); + assert_eq!(plans[2].deadline, Duration::from_secs(3)); + assert_eq!(plans[3].deadline, Duration::from_secs(3)); + assert_eq!(plans[0].args[0], OsString::from("clone")); + assert!(plans[0].args.contains(&OsString::from("--bare"))); + assert!(plans[1].args.windows(3).any(|w| w + == [ + OsString::from("worktree"), + OsString::from("add"), + OsString::from("-b") + ])); +} + +#[tokio::test] +async fn workspace_dispose_removes_linked_worktree_and_last_shared_bare_repository() { + 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 service = LoopxWorkspaceService::with_runner( + LoopxWorkspaceServiceConfig::new(&root, "git"), + 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 prepared = service + .prepare(LoopxWorkspacePrepareRequest { + operation_id: "workspace-dispose-prepare".to_string(), + task_id: "task-42".to_string(), + item: item.clone(), + }) + .await + .unwrap(); + + // A marker outside the worktree keeps dispose from touching it. + let worktree = PathBuf::from(&prepared.worktree_path); + assert!(worktree.exists()); + let bare = worktree.parent().expect("worktree parent").join("bare.git"); + assert!(bare.exists()); + + let disposed = service + .dispose(LoopxWorkspaceDisposeRequest { + operation_id: "workspace-dispose".to_string(), + task_id: "task-42".to_string(), + item, + }) + .await + .unwrap(); + assert!(disposed.removed); + assert!(!worktree.exists()); + // Last linked worktree was removed, so the bare repository is gone too. + assert!(!bare.exists()); + + let plans = runner.plans.lock().unwrap(); + assert!(plans.iter().any(|plan| { + plan.args.windows(3).any(|w| { + w == [ + OsString::from("worktree"), + OsString::from("remove"), + OsString::from("--force"), + ] + }) + })); + assert!(plans.iter().any(|plan| { + plan.args + .windows(2) + .any(|w| w == [OsString::from("worktree"), OsString::from("list")]) + })); +} + +#[tokio::test] +async fn workspace_dispose_keeps_shared_bare_repository_while_other_worktrees_exist() { + 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 service = LoopxWorkspaceService::with_runner( + LoopxWorkspaceServiceConfig::new(&root, "git"), + runner.clone(), + Arc::new(NoopLoopxProcessObserver), + ); + let repo = LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "owner".to_string(), + repository: "repo".to_string(), + }; + let item_42 = LoopxIssueKey { + repository: repo.clone(), + kind: LoopxItemKind::Issue, + number: 42, + }; + let item_43 = LoopxIssueKey { + repository: repo, + kind: LoopxItemKind::Issue, + number: 43, + }; + + let first = service + .prepare(LoopxWorkspacePrepareRequest { + operation_id: "workspace-shared-prepare-1".to_string(), + task_id: "task-42".to_string(), + item: item_42.clone(), + }) + .await + .unwrap(); + let second = service + .prepare(LoopxWorkspacePrepareRequest { + operation_id: "workspace-shared-prepare-2".to_string(), + task_id: "task-43".to_string(), + item: item_43.clone(), + }) + .await + .unwrap(); + let bare = PathBuf::from(&first.worktree_path) + .parent() + .expect("worktree parent") + .join("bare.git"); + assert!(bare.exists()); + // Same repository shares one bare object database. + assert_eq!( + Path::new(&first.worktree_path) + .parent() + .expect("first parent"), + Path::new(&second.worktree_path) + .parent() + .expect("second parent") + ); + + // Removing only one worktree keeps the shared bare repository. + let disposed = service + .dispose(LoopxWorkspaceDisposeRequest { + operation_id: "workspace-shared-dispose-1".to_string(), + task_id: "task-42".to_string(), + item: item_42, + }) + .await + .unwrap(); + assert!(disposed.removed); + assert!(!Path::new(&first.worktree_path).exists()); + assert!(bare.exists()); + + // Removing the last worktree removes the bare repository as well. + let final_dispose = service + .dispose(LoopxWorkspaceDisposeRequest { + operation_id: "workspace-shared-dispose-2".to_string(), + task_id: "task-43".to_string(), + item: item_43, + }) + .await + .unwrap(); + assert!(final_dispose.removed); + assert!(!bare.exists()); +} + +#[tokio::test] +async fn workspace_probe_checks_git_root_writability_and_repository_access() { + 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 service = LoopxWorkspaceService::with_runner( + LoopxWorkspaceServiceConfig::new(&root, "git"), + runner.clone(), + Arc::new(NoopLoopxProcessObserver), + ); + + let result = service + .probe(LoopxWorkspaceProbeRequest { + operation_id: "workspace-probe".to_string(), + repository: Some(LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "owner".to_string(), + repository: "repo".to_string(), + }), + }) + .await + .unwrap(); + + assert_eq!(result.git_version.as_deref(), Some("git version 2.53.0")); + assert!(result.repository_verified); + assert!(Path::new(&result.workspace_root).is_absolute()); + let plans = runner.plans.lock().unwrap(); + assert_eq!(plans.len(), 2); + assert_eq!(plans[0].args, [OsString::from("--version")]); + assert_eq!(plans[1].args[0], OsString::from("ls-remote")); + assert!(plans[1] + .args + .contains(&OsString::from("https://github.com/owner/repo.git"))); +} + +#[tokio::test] +async fn workspace_probe_surfaces_the_actionable_git_stderr() { + let temporary = tempfile::tempdir().unwrap(); + let runner = Arc::new(FakeRunner::with_results([Err(LoopxProcessError::Exited { + code: Some(128), + stdout_tail: Vec::new(), + stderr_tail: vec!["fatal: unable to access repository".to_string()], + })])); + let service = LoopxWorkspaceService::with_runner( + LoopxWorkspaceServiceConfig::new(temporary.path().join("workspaces"), "git"), + runner, + Arc::new(NoopLoopxProcessObserver), + ); + + let error = service + .probe(LoopxWorkspaceProbeRequest { + operation_id: "workspace-probe-error".to_string(), + repository: None, + }) + .await + .unwrap_err(); + + assert!(error.message.contains("status Some(128)")); + assert!(error.message.contains("fatal: unable to access repository")); +} + +#[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 (
    = ({ appId }) => { const markCustomizationActive = useMiniAppStore((state) => state.markCustomizationActive); const markCustomizationIdle = useMiniAppStore((state) => state.markCustomizationIdle); const { current: appearance } = useAppearance(); - const appearanceMode = appearance?.mode ?? 'dark'; + const appearanceMode = resolveMiniAppAppearanceMode(appearance); const { workspace, workspacePath } = useCurrentWorkspace(); const { closeScene } = useSceneManager(); const { t, currentLanguage } = useI18n('scenes/miniapp'); diff --git a/src/web-ui/src/app/scenes/miniapps/hooks/loopxBridgeProtocol.ts b/src/web-ui/src/app/scenes/miniapps/hooks/loopxBridgeProtocol.ts new file mode 100644 index 0000000000..2b4cc69f69 --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/hooks/loopxBridgeProtocol.ts @@ -0,0 +1,310 @@ +import type { + LoopxActionKind, + LoopxActionRequest, + LoopxAttachRequest, + LoopxCreateTaskRequest, + LoopxEventsSinceRequest, + LoopxIssueKey, + LoopxItemKind, + LoopxPermissionScope, + LoopxResolveIntakeRequest, + LoopxTurnOutputSinceRequest, +} from '@/infrastructure/api/service-api/MiniAppAPI'; + +export const LOOPX_BUILTIN_APP_ID = 'builtin-bitfun-loopx'; + +type LoopxBridgeCall = + | { kind: 'attach'; request: LoopxAttachRequest } + | { kind: 'listModels' } + | { kind: 'resolveIntake'; request: LoopxResolveIntakeRequest } + | { kind: 'createTask'; request: LoopxCreateTaskRequest } + | { kind: 'action'; request: LoopxActionRequest } + | { kind: 'eventsSince'; request: LoopxEventsSinceRequest } + | { kind: 'turnOutputSince'; request: LoopxTurnOutputSinceRequest }; + +const LOOPX_METHODS = new Set([ + 'loopx.attach', + 'loopx.listModels', + 'loopx.resolveIntake', + 'loopx.createTask', + 'loopx.action', + 'loopx.eventsSince', + 'loopx.turnOutputSince', +]); + +const HOST_CONTROLLED_KEYS = new Set([ + 'argv', + 'argvprefix', + 'binary', + 'cliargs', + 'command', + 'cwd', + 'executable', + 'executiondomain', + 'peerdeviceid', + 'projectdir', + 'workspacepath', + 'worktreepath', +]); + +const ITEM_KINDS = new Set(['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', +'abort', + 'resume', + 'resume_repository', + 'reset_all', + '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 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: parseRepositoryKey(item.repository, `${path}.repository`), + kind: kind as LoopxItemKind, + number: unsignedInteger(item.number, `${path}.number`), + }; +} + +function parseRepositoryKey(value: unknown, path: string) { + const repository = asRecord(value, path); + assertAllowedKeys(repository, ['host', 'owner', 'repository'], path); + return { + host: requiredString(repository.host, `${path}.host`), + owner: requiredString(repository.owner, `${path}.owner`), + repository: requiredString(repository.repository, `${path}.repository`), + }; +} + +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', + 'repository', + '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'), + repository: rawParams.repository == null + ? undefined + : parseRepositoryKey(rawParams.repository, 'params.repository'), + 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'), + }, + }; + } + + if (method === 'loopx.eventsSince') { + 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'), + }, + }; + } + + assertAllowedKeys(rawParams, ['taskId', 'turnId', 'streamId', 'afterCursor', 'limit'], 'params'); + return { + kind: 'turnOutputSince', + request: { + taskId: requiredString(rawParams.taskId, 'params.taskId'), + turnId: optionalString(rawParams.turnId, 'params.turnId'), + streamId: optionalString(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..a15d97c52b 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,17 @@ 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(), + loopxTurnOutputSince: vi.fn(), apiListen: vi.fn(), openMainSession: vi.fn(), addExternalSession: vi.fn(), @@ -29,6 +38,13 @@ 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, + loopxTurnOutputSince: mocks.loopxTurnOutputSince, }, })); @@ -39,7 +55,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 +138,23 @@ function BridgeHarness() { return