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..2e78890b04 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) @@ -217,6 +217,14 @@ MiniApp 框架**只暴露下列能力**,没有任何"通用 BitFun 后端通 > 维护者:以后若新增 `app.bitfun.*` / `app.workspace.*` 这类宿主直通通道,请同步更新本节,避免"文档说没有、代码偷偷加了"的不一致。 +### 内置产品私有扩展 + +源码、来源和运行域都由宿主验证的内置产品界面可以获得私有 namespace,但它不属于 +MiniApp 公共 API,也不会注入普通、导入或市场 MiniApp。当前仅 +`builtin-bitfun-loopx` 使用私有 `app.loopx` 连接持久宿主控制器;每次调用仍由宿主 +复核原始 bundle、非 draft、非本地覆盖和本地执行域。生成 MiniApp 不得探测、声明或 +模拟这些私有 namespace;需要复用的能力必须先形成产品无关、带权限合同的公开 API。 + ## window.app 运行时 API MiniApp UI 内通过 **window.app** 访问: diff --git a/MiniApp/Skills/miniapp-dev/api-reference.md b/MiniApp/Skills/miniapp-dev/api-reference.md index b62f1c1e20..85c5e86150 100644 --- a/MiniApp/Skills/miniapp-dev/api-reference.md +++ b/MiniApp/Skills/miniapp-dev/api-reference.md @@ -114,6 +114,16 @@ app.platform // 'win32' | 'darwin' | 'linux' app.mode // 'hosted' ``` +### 内置产品私有扩展不属于公共 API + +宿主可以为源码和来源均通过校验的内置产品界面注入私有 namespace。此类 namespace +不会进入普通或市场 MiniApp 的编译结果,也不属于 `window.app` 公共能力合同。 +当前 `builtin-bitfun-loopx` 使用私有 `app.loopx` 连接持久宿主控制器;宿主在每次调用时 +还会校验内置 id、原始 bundle 内容、非 draft/非本地覆盖状态和执行域。 + +生成、导入和市场 MiniApp 不得声明、探测或依赖 `app.loopx`,也不得以自定义 Worker +模拟该控制器。需要类似能力时应先建立新的公开、产品无关且有权限合同的 MiniApp API。 + ### `app.fs.*` — 文件系统 需在 `permissions.fs` 中声明读写范围。 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_api.rs b/src/apps/desktop/src/api/miniapp_api.rs index 9bf92d8794..f40e51f2a1 100644 --- a/src/apps/desktop/src/api/miniapp_api.rs +++ b/src/apps/desktop/src/api/miniapp_api.rs @@ -1606,12 +1606,7 @@ pub async fn miniapp_ai_list_models( 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 - ) - }), + supports_text_chat: model.supports_text_generation(), }), ai_perms.allowed_models.as_deref().unwrap_or(&[]), &primary_id, 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..025870e608 --- /dev/null +++ b/src/apps/desktop/src/api/miniapp_loopx_api.rs @@ -0,0 +1,271 @@ +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.supports_text_generation(), + }), + &[], + &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); + } + if request.input.resume_detected { + let resume_controller = controller.controller.clone(); + tauri::async_runtime::spawn(async move { + if let Err(error) = resume_controller.handle_host_resume().await { + log::warn!("LoopX host resume reconciliation failed: {error}"); + } + }); + } + 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_playbooks/im_send_message.yaml b/src/crates/assembly/core/builtin_playbooks/im_send_message.yaml index 6f0ab8f838..9e5ad035d6 100644 --- a/src/crates/assembly/core/builtin_playbooks/im_send_message.yaml +++ b/src/crates/assembly/core/builtin_playbooks/im_send_message.yaml @@ -32,14 +32,14 @@ parameters: - name: search_chord description: "Keyboard shortcut to focus the in-app search box. Default ['command','f'] (macOS)." required: false - default: ["command", "f"] + default: '["command", "f"]' - name: send_keys description: | Chord that submits the message in this app. Default ['return'] works for WeChat/iMessage/Telegram. Use ['command','return'] for Slack/Lark where Return inserts a newline. required: false - default: ["return"] + default: '["return"]' steps: - domain: system 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/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 0c84931852..8f58393ef0 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -5886,28 +5886,29 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .or(remote_ssh_host.as_deref()), ) .await?; - match self - .restore_session_from_storage_path(&restore_path, &session_id) - .await - { - Ok(_) => { - let restored_messages = self - .session_manager - .get_context_messages(&session_id) - .await?; - info!( - "Session history restored from persistence: session_id={}, messages: {} -> {}", - session_id, - context_messages.len(), - restored_messages.len() - ); - } - Err(e) => { - debug!( - "Failed to restore session history (may be new session): session_id={}, error={}", - session_id, e - ); - } + let persisted_metadata = self + .session_manager + .persistence_manager() + .load_session_metadata(&restore_path, &session_id) + .await?; + if persisted_metadata.is_none() { + debug!( + "Session history restore skipped for new session: session_id={}", + session_id + ); + } else { + self.restore_session_from_storage_path(&restore_path, &session_id) + .await?; + let restored_messages = self + .session_manager + .get_context_messages(&session_id) + .await?; + info!( + "Session history restored from persistence: session_id={}, messages: {} -> {}", + session_id, + context_messages.len(), + restored_messages.len() + ); } } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/playbook_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/playbook_tool.rs index 612962ba20..355c9e50df 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/playbook_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/playbook_tool.rs @@ -144,6 +144,11 @@ impl PlaybookTool { /// Try to parse a string as a native JSON type (number / bool), falling /// back to a JSON string. fn parse_typed_value(s: &str) -> Value { + if let Ok(value) = serde_json::from_str::(s) { + if value.is_array() || value.is_object() || value.is_null() { + return value; + } + } if let Ok(n) = s.parse::() { return json!(n); } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/web/fetch.rs b/src/crates/assembly/core/src/agentic/tools/implementations/web/fetch.rs index c648d7b22c..105b8a5c29 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/web/fetch.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/web/fetch.rs @@ -7,7 +7,7 @@ use crate::agentic::tools::framework::{ }; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; -use bitfun_services_integrations::web_tools::WebToolNetworkProvider; +use bitfun_services_integrations::web_tools::{WebToolNetworkError, WebToolNetworkProvider}; use serde_json::{json, Value}; /// WebFetch tool @@ -42,6 +42,8 @@ Use this tool to: Best for static pages that need no login. For pages requiring the user's login session or JavaScript rendering, use ControlHub domain="browser" instead: connect -> navigate -> snapshot / read_article. Chrome 144+ and Edge can connect to the user's current profile after explicit approval, preserving tabs and login state; other supported Chromium browsers reuse a real-profile endpoint when available and otherwise use BitFun's persistent managed profile. (browser.fetch only works when a session is already connected and the current page is same-origin with the target URL — it runs inside that page and is subject to its CORS policy.) +HTTP 401, 403, and 429 responses are returned as structured access restrictions, not readable page content. Do not retry the same URL in the same turn. For public GitHub API rate limits, use the public github.com page, WebSearch, or an authenticated Git/review provider. For login-gated pages, use the user's connected browser session. + Supports different output formats: - raw: Raw response content (original HTML or text) - markdown: Readable content mode. For HTML pages, BitFun extracts the main content and returns markdown when possible, automatically falling back to plain text when markdown conversion is not reliable. @@ -159,9 +161,15 @@ Example usage: let requested_format = normalize_requested_format(input.get("format").and_then(|v| v.as_str()))?; - let response = WebToolNetworkProvider::fetch_text(url) - .await - .map_err(|error| BitFunError::tool(error.to_string()))?; + let response = match WebToolNetworkProvider::fetch_text(url).await { + Ok(response) => response, + Err(error) => { + if let Some(result) = handled_access_result(url, &error) { + return Ok(vec![result]); + } + return Err(BitFunError::tool(error.to_string())); + } + }; let content_type = response.content_type; let content = response.content; @@ -215,3 +223,59 @@ Example usage: Ok(vec![result]) } } + +pub(super) fn handled_access_result(url: &str, error: &WebToolNetworkError) -> Option { + let WebToolNetworkError::HttpStatus { + status_code, + status, + reason, + retry_after, + rate_limit_remaining, + rate_limit_reset, + } = error + else { + return None; + }; + if !matches!(*status_code, 401 | 403 | 429) { + return None; + } + + let github_rate_limited = url.starts_with("https://api.github.com/") + && (rate_limit_remaining.as_deref() == Some("0") || *status_code == 429); + let category = if github_rate_limited { + "rate_limited" + } else if *status_code == 401 { + "authentication_required" + } else if *status_code == 429 { + "rate_limited" + } else { + "access_denied" + }; + let guidance = if github_rate_limited { + "Do not retry this GitHub API URL in this turn. Use the public github.com page, WebSearch, or an authenticated Git/review provider." + } else if *status_code == 401 { + "This page requires authentication. Use the user's connected browser session or an authenticated provider." + } else if *status_code == 429 { + "The server rate-limited direct fetching. Respect Retry-After when present and use another available source in this turn." + } else { + "The server denied direct fetching. Do not retry the same URL in this turn; use WebSearch or the user's connected browser session when appropriate." + }; + let assistant_text = + format!("WebFetch access restriction: HTTP {status} ({reason}). {guidance}"); + + Some(ToolResult::Result { + data: json!({ + "url": url, + "status": category, + "http_status": status_code, + "reason": reason, + "retry_after": retry_after, + "rate_limit_remaining": rate_limit_remaining, + "rate_limit_reset": rate_limit_reset, + "retry_same_url": false, + "guidance": guidance, + }), + result_for_assistant: Some(assistant_text), + image_attachments: None, + }) +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/web/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/web/mod.rs index ee57ad1ad5..906203d0a3 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/web/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/web/mod.rs @@ -9,13 +9,14 @@ pub use search::WebSearchTool; #[cfg(test)] mod tests { - use super::fetch::WebFetchTool; + use super::fetch::{handled_access_result, WebFetchTool}; use super::readable::{ extract_html_title, extract_markdown_with_text_fallback, html_to_text, is_html, looks_noisy, normalize_requested_format, RequestedFormat, }; use super::search::{build_web_search_tool_result, WebSearchTool}; use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; + use bitfun_services_integrations::web_tools::WebToolNetworkError; use serde_json::json; use std::io::ErrorKind; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -187,6 +188,38 @@ mod tests { // other browsers may use a persistent managed profile. assert!(description.contains("current profile")); assert!(description.contains("managed profile")); + assert!(description.contains("Do not retry the same URL")); + } + + #[test] + fn webfetch_github_rate_limit_is_a_structured_access_result() { + let result = handled_access_result( + "https://api.github.com/repos/owner/repo/issues/1", + &WebToolNetworkError::HttpStatus { + status_code: 403, + status: "403 Forbidden".to_string(), + reason: "Forbidden".to_string(), + retry_after: None, + rate_limit_remaining: Some("0".to_string()), + rate_limit_reset: Some("1234".to_string()), + }, + ) + .expect("rate limits should be handled"); + + let ToolResult::Result { + data, + result_for_assistant, + .. + } = result + else { + panic!("expected structured result"); + }; + assert_eq!(data["status"], "rate_limited"); + assert_eq!(data["http_status"], 403); + assert_eq!(data["retry_same_url"], false); + assert!(result_for_assistant + .as_deref() + .is_some_and(|text| text.contains("Do not retry"))); } #[test] 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..65c4767137 --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/agent_adapter.rs @@ -0,0 +1,567 @@ +use super::tool_activity::project_tool_activity; +use crate::agentic::coordination::ConversationCoordinator; +use bitfun_events::AgenticEvent; +use bitfun_product_domains::miniapp::loopx::{ + LoopxAgentCancelRequest, LoopxAgentCancelResult, LoopxAgentFinishRequest, + LoopxAgentFinishResult, LoopxAgentOutputSinceRequest, LoopxAgentOutputSinceResult, + LoopxAgentPort, LoopxAgentProbeRequest, LoopxAgentProbeResult, LoopxAgentResetRequest, + LoopxAgentResetResult, LoopxAgentStartRequest, LoopxAgentStartResult, LoopxHostFuture, + LoopxHostPortError, LoopxHostPortErrorKind, LoopxTurnOutputEvent, LoopxTurnOutputEventKind, + LOOPX_BUILTIN_APP_ID, +}; +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.supports_text_generation() { + return Err(host_error( + LoopxHostPortErrorKind::Unsupported, + format!("LoopX Agent model '{model_id}' does not support text chat"), + &request.operation_id, + )); + } + let supports_images = model.supports_image_understanding(); + 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 task_id = request.task_id.clone(); + let metadata = loopx_session_metadata(&request); + 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))?; + log::info!( + "LoopX transient Agent session created: task_id={}, session_id={}, requested_turn_id={}", + task_id, + created.session_id, + turn_id + ); + let submitted = AgentSubmissionPort::submit_message( + self.coordinator.as_ref(), + AgentSubmissionRequest { + session_id: created.session_id.clone(), + message: with_host_execution_context(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, + )); + } + log::info!( + "LoopX Agent turn accepted: task_id={}, session_id={}, turn_id={}", + task_id, + created.session_id, + submitted.turn_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, + ) + })?; + log::info!( + "LoopX transient Agent session discarded: task_id={}, session_id={}, discarded={}", + request.task_id, + request.session_id, + discarded + ); + 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 with_host_execution_context(prompt: String) -> String { + format!( + r#" +The current workspace is already a dedicated task worktree. Work directly in it; do not create a nested or sibling worktree. +Before installing dependencies, inspect the existing install state. Package-manager download caches are shared by the host, but mutable node_modules and build outputs remain worktree-local. +Only call WriteStdin or ExecControl when the immediately preceding tool result returned a concrete session_id. If no session_id was returned, use a new bounded foreground command or inspect the process/result artifact with a new command. +Prefer targeted checks before repository-wide installs, builds, packaging, or end-to-end smoke tests. Every wait must have a bounded deadline; when it expires, record the evidence and choose a recovery or narrower next action instead of starting an indefinite sleep-poll loop. + + +{prompt}"# + ) +} + +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, + .. + } => { + let activity = project_tool_activity(&tool_event)?; + let text = activity + .details + .get("summary") + .cloned() + .unwrap_or(activity.message); + Some(LoopxTurnOutputEvent { + cursor, + turn_id, + round_id: Some(round_id), + kind: LoopxTurnOutputEventKind::Tool, + text: Some(text), + tool_name: Some(activity.tool_name), + tool_state: Some(activity.state.to_string()), + ..LoopxTurnOutputEvent::default() + }) + } + _ => None, + } +} + +fn loopx_session_metadata( + request: &LoopxAgentStartRequest, +) -> serde_json::Map { + serde_json::Map::from_iter([ + ("surface".to_string(), serde_json::json!("miniapp_agent")), + ("appId".to_string(), serde_json::json!(LOOPX_BUILTIN_APP_ID)), + ( + "loopxTaskId".to_string(), + serde_json::json!(request.task_id.clone()), + ), + ( + "generation".to_string(), + serde_json::json!(request.generation), + ), + ( + "goalId".to_string(), + serde_json::json!(request.metadata.goal_id.clone()), + ), + ( + "loopxTurnId".to_string(), + serde_json::json!(request.metadata.loopx_turn_id.clone()), + ), + ]) +} + +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, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_events::{ToolEventData, ToolEventIdentity}; + use bitfun_product_domains::miniapp::loopx::{ + LoopxAgentTurnMetadata, LoopxIssueKey, LoopxItemKind, LoopxRepositoryKey, + }; + + #[test] + fn loopx_sessions_use_the_transient_miniapp_surface_contract() { + let request = LoopxAgentStartRequest { + task_id: "task-1".to_string(), + generation: 2, + metadata: LoopxAgentTurnMetadata { + goal_id: "goal-1".to_string(), + loopx_turn_id: "turn-1".to_string(), + item: LoopxIssueKey { + repository: LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "owner".to_string(), + repository: "repo".to_string(), + }, + kind: LoopxItemKind::Issue, + number: 42, + }, + attempt: 1, + }, + ..LoopxAgentStartRequest::default() + }; + + let metadata = loopx_session_metadata(&request); + assert_eq!(metadata["surface"], serde_json::json!("miniapp_agent")); + assert_eq!(metadata["appId"], serde_json::json!(LOOPX_BUILTIN_APP_ID)); + } + + #[test] + fn loopx_prompt_carries_host_execution_constraints_without_workflow_policy() { + let prompt = with_host_execution_context("LoopX-owned turn contract".to_string()); + + assert!(prompt.contains("already a dedicated task worktree")); + assert!(prompt.contains("concrete session_id")); + assert!(prompt.contains("Every wait must have a bounded deadline")); + assert!(prompt.ends_with("LoopX-owned turn contract")); + } + + #[test] + fn live_output_omits_partial_tool_parameters() { + let event = AgenticEvent::ToolEvent { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::ParamsPartial { + identity: ToolEventIdentity::direct("tool-1", "ExecCommand"), + params: "{\"cmd\":\"cargo".to_string(), + }, + }; + + assert!(turn_output_event(1, "turn-1", event).is_none()); + } +} 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..cf84db9252 --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/controller.rs @@ -0,0 +1,3269 @@ +use super::tool_activity::ToolActivityProjection; +use super::{LoopxPersistedState, LoopxStateStore, LoopxTaskRuntimeRecord}; +use bitfun_product_domains::miniapp::loopx::*; +use std::collections::{BTreeMap, HashMap}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex as StdMutex}; +use std::time::{Duration, Instant, 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; +const MAX_AGENT_SUMMARY_CHARS: usize = 16_000; +const GOAL_RECONCILE_TTL_MS: i64 = 30_000; +const GOAL_RECONCILE_DEADLINE_MS: i64 = 30_000; +/// Budget is counted at verified durable settlements, never at tool-call or +/// prompt-pattern level. A natural LoopX gate or Goal terminal state wins +/// before this policy is considered. +const AUTONOMOUS_TURN_BUDGET: u32 = 4; +const AUTONOMY_REVIEW_ACTION_KIND: &str = "autonomous_budget_review"; +/// loopx 0.5.1's turn plan exposes only cadence labels, never a numeric +/// scheduler hint, so the host owns the heartbeat cadence for waiting goals. +const WAIT_RESCHEDULE_FALLBACK_MS: u64 = 60_000; + +struct ScheduledTask { + task_id: String, +} + +struct ResetInProgressGuard<'a>(&'a AtomicBool); + +impl Drop for ResetInProgressGuard<'_> { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +#[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<()>, + reconcile_lock: Mutex<()>, + previews: RwLock>, + active_tasks: Mutex>, + active_repositories: Mutex>, + event_sender: broadcast::Sender, + task_sender: mpsc::UnboundedSender, + load_error: RwLock>, + reset_in_progress: AtomicBool, +} + +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(()), + reconcile_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), + reset_in_progress: AtomicBool::new(false), + }); + 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 environment_ready = + self.state.read().await.environment.status == LoopxEnvironmentStatus::Ready; + if execution_support == LoopxExecutionSupport::Supported + && environment_ready + && self.load_error.read().await.is_none() + { + self.reconcile_goal_projections(false).await; + } + 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 } + } + + /// Re-hydrates LoopX-owned projections after the trusted Desktop surface + /// observes a suspend/resume clock discontinuity. Active Agent turns are + /// preserved: Windows can resume their subprocess tree successfully, so + /// this path invalidates stale clients and refreshes only read-only host + /// facts instead of manufacturing a failure or duplicate turn. + pub async fn handle_host_resume(self: &Arc) -> Result<(), String> { + if self.reset_in_progress.load(Ordering::Acquire) { + return Ok(()); + } + { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let start_cursor = state.cursor; + state.revision = state.revision.saturating_add(1); + state.append_event(LoopxEvent { + kind: LoopxEventKind::SnapshotInvalidated, + level: LoopxEventLevel::Info, + source: LoopxEventSource::Controller, + message: "Host resumed; refreshing LoopX projections".to_string(), + occurred_at: now_ms(), + ..LoopxEvent::default() + }); + let persisted = state.clone(); + drop(state); + self.store.save(&persisted).await?; + self.broadcast_new_events(&persisted, start_cursor); + } + + if let Err(error) = self.refresh_environment().await { + log::warn!("LoopX environment refresh after host resume failed: {error}"); + } + self.reconcile_goal_projections(true).await; + Ok(()) + } + + /// Refresh the read-only LoopX Goal projection before presenting persisted + /// host jobs. Failures preserve the last local projection and are surfaced + /// in logs; they never manufacture a Goal transition or local fallback. + async fn reconcile_goal_projections(&self, force: bool) { + let Ok(_reconcile) = self.reconcile_lock.try_lock() else { + return; + }; + let now = now_ms(); + let candidates = { + let state = self.state.read().await; + state + .tasks + .iter() + .filter(|task| { + task.goal_id.as_deref().is_some_and(|id| !id.is_empty()) + && task + .workspace_path + .as_deref() + .is_some_and(|path| !path.is_empty()) + && !matches!( + task.state, + LoopxTaskState::Preparing + | LoopxTaskState::Running + | LoopxTaskState::Cancelling + | LoopxTaskState::Aborted + | LoopxTaskState::Archived + ) + // Passive states change through explicit host actions. Re- + // inspecting them on every UI attach only spawns sidecar + // processes that can time out. Suspend/resume keeps the force + // path so an externally changed Goal is still repaired. + && (force + || (task.state == LoopxTaskState::WaitingForUser + && task.pending_gate_id.is_none()) + || !matches!( + task.state, + LoopxTaskState::WaitingForUser + | LoopxTaskState::Completed + | LoopxTaskState::Stopped + | LoopxTaskState::Failed + | LoopxTaskState::RecoveryRequired + )) + && (force + || task.goal_state.is_none() + || now.saturating_sub(task.updated_at) >= GOAL_RECONCILE_TTL_MS) + }) + .filter_map(|task| { + let runtime = state.runtime.get(&task.task_id)?.clone(); + (!runtime.registry_path.is_empty()).then(|| (task.clone(), runtime)) + }) + .collect::>() + }; + + for (task, runtime) in candidates { + let mut context = goal_context(&task, &runtime); + context.call.operation_id = + format!("reconcile-goal-{}-{}", task.task_id, uuid::Uuid::new_v4()); + context.call.deadline_at = Some(now_ms().saturating_add(GOAL_RECONCILE_DEADLINE_MS)); + let progress = BufferedProgress::default(); + let result = self + .cli + .inspect_goal( + LoopxCliInspectGoalRequest { + context, + 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; + if let Err(error) = self.record_progress(progress.take()).await { + log::warn!( + "Failed to persist LoopX reconciliation progress: task_id={}, error={}", + task.task_id, + error + ); + } + let snapshot = match result { + Ok(snapshot) => snapshot, + Err(error) => { + log::warn!( + "LoopX Goal reconciliation failed: task_id={}, goal_id={}, error={}", + task.task_id, + task.goal_id.as_deref().unwrap_or("unknown"), + error + ); + continue; + } + }; + if let Err(error) = self.apply_goal_projection(&task, &snapshot).await { + log::warn!( + "Failed to apply LoopX Goal projection: task_id={}, goal_id={}, error={}", + task.task_id, + snapshot.goal_id, + error + ); + } + } + } + + 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?; + self.reconcile_goal_projections(true).await; + Ok(()) + } + + 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(()); + } + log::info!( + "LoopX Agent terminal handling started: task_id={}, goal_id={}, agent_turn_id={}, status={:?}", + task.task_id, + task.goal_id.as_deref().unwrap_or("unknown"), + turn_id, + status + ); + if let Some(summary) = summary + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let bounded = bounded_agent_summary(summary); + self.mutate_task(&task.task_id, None, |current, _| { + if current.generation != task.generation { + return; + } + current.last_agent_summary = Some(bounded); + current.last_agent_summary_at = Some(now_ms()); + current.revision = current.revision.saturating_add(1); + }) + .await?; + } + self.update_task_phase( + &task.task_id, + task.generation, + LoopxPhase::ValidatingProgress, + "Agent turn ended; finalizing LoopX-owned durable settlement", + ) + .await?; + let progress = BufferedProgress::default(); + let settlement_started = Instant::now(); + let result = self + .cli + .finalize_turn_settlement( + 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; + match &result { + Ok(settlement) => log::info!( + "LoopX turn settlement completed: task_id={}, goal_id={}, loopx_turn_id={}, status={:?}, duration_ms={}, scheduler_hint_ms={:?}", + task.task_id, + task.goal_id.as_deref().unwrap_or("unknown"), + settlement.turn_id, + settlement.status, + settlement_started.elapsed().as_millis(), + settlement.scheduler_hint_ms + ), + Err(error) => log::warn!( + "LoopX turn settlement failed: task_id={}, goal_id={}, duration_ms={}, error={}", + task.task_id, + task.goal_id.as_deref().unwrap_or("unknown"), + settlement_started.elapsed().as_millis(), + error + ), + } + 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 &finish_result { + Ok(finish) => log::info!( + "LoopX transient Agent session finished: task_id={}, session_id={}, discarded={}", + task.task_id, + finish.session_id, + finish.discarded + ), + Err(error) => log::warn!( + "LoopX transient Agent session cleanup failed: task_id={}, error={}", + task.task_id, + error + ), + } + 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(super) async fn handle_agent_activity(&self, turn_id: &str) -> 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(()); + }; + self.mutate_task(&task_id, None, |task, _| { + if task.state != LoopxTaskState::Running { + return; + } + task.last_output_at = Some(now_ms()); + task.revision = task.revision.saturating_add(1); + }) + .await?; + Ok(()) + } + + pub(super) async fn handle_agent_tool_activity( + &self, + turn_id: &str, + activity: ToolActivityProjection, + ) -> Result<(), String> { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let Some(task_id) = state + .runtime + .iter() + .find(|(_, runtime)| runtime.agent_turn_id.as_deref() == Some(turn_id)) + .map(|(task_id, _)| task_id.clone()) + else { + return Ok(()); + }; + let Some(task_index) = state.tasks.iter().position(|task| task.task_id == task_id) else { + return Ok(()); + }; + if state.tasks[task_index].state != LoopxTaskState::Running { + return Ok(()); + } + + let now = now_ms(); + { + let task = &mut state.tasks[task_index]; + task.last_output_at = Some(now); + task.updated_at = now; + task.current_tool = activity.current_tool.clone(); + task.revision = task.revision.saturating_add(1); + } + let updated = state.tasks[task_index].clone(); + state.revision = state.revision.saturating_add(1); + state.append_event(LoopxEvent { + task_id: Some(updated.task_id.clone()), + generation: Some(updated.generation), + revision: Some(updated.revision), + kind: LoopxEventKind::Log, + level: if activity.important { + LoopxEventLevel::Error + } else { + LoopxEventLevel::Info + }, + source: LoopxEventSource::Agent, + phase: Some(updated.phase), + message: activity.message, + important: activity.important, + tool_name: Some(activity.tool_name), + details: activity.details, + occurred_at: now, + ..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 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?; + self.record_goal_state(&task, inspected.state).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; + // loopx 0.5.1 never emits a numeric scheduler hint, and a + // waiting goal with no requeue would sleep forever. The host + // owns the heartbeat cadence: honor an explicit hint when one + // exists, otherwise fall back to a bounded polling interval. + let delay = inspected + .scheduler_hint_ms + .unwrap_or(WAIT_RESCHEDULE_FALLBACK_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; + let durable_revision = inspected.durable_revision.clone(); + self.release_repository(&task).await; + let updated = self + .mutate_task(&task_id, None, |current, current_runtime| { + if current.generation != task.generation { + return; + } + current.state = LoopxTaskState::WaitingForUser; + current.phase = LoopxPhase::WaitingForApproval; + current.pending_gate_id = Some(gate_id.clone()); + current.pending_gate_message = Some(message.clone()); + current.pending_gate_action_kind = action_kind.clone(); + current.revision = current.revision.saturating_add(1); + current_runtime.expected_durable_revision = + Some(durable_revision.clone()); + }) + .await?; + let mut details = BTreeMap::new(); + details.insert("gateId".to_string(), gate_id.clone()); + if let Some(action_kind) = action_kind.clone() { + 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 autonomous_turns = autonomous_turns_since_review( + &task, + inspected.settlement_receipt_ids.len() as u32, + ); + if autonomy_review_required(autonomous_turns) { + return self + .require_autonomy_review(&task, &runtime, autonomous_turns) + .await; + } + 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 local record owns only host-job cleanup; LoopX + /// remains authoritative for Goal lifecycle, so teardown failures are + /// logged and the operator action 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.pending_gate_id = None; + task.pending_gate_message = None; + task.pending_gate_action_kind = None; + task.error = None; + task.autonomous_turns_since_review = 0; + 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 { + if self + .reset_in_progress + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Duplicate, + current_revision: self.state.read().await.revision, + message: Some("LoopX reset is already in progress".to_string()), + ..LoopxActionResponse::default() + }); + } + let _reset_guard = ResetInProgressGuard(&self.reset_in_progress); + let (tasks, runtimes, previous_stream_id, environment) = { + 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(); + let environment = state.environment.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, environment) + }; + + 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())?; + let goal_ids = tasks + .iter() + .map(|task| goal_id_for(&task.identity)) + .collect::>() + .into_iter() + .collect::>(); + let reset_goals = if goal_ids.is_empty() { + LoopxCliResetGoalsResult::default() + } else { + let progress = BufferedProgress::default(); + let result = self + .cli + .reset_goals( + LoopxCliResetGoalsRequest { + call: LoopxCliCallContext { + operation_id: format!("reset-goals-{}", uuid::Uuid::new_v4()), + deadline_at: None, + }, + goal_ids, + }, + &progress, + ) + .await + .map_err(|error| error.to_string())?; + self.record_progress(progress.take()).await?; + result + }; + log::info!( + "LoopX reset goal cleanup completed: requested={}, retired={}, already_absent={}, archived={}, missing_runtime={}", + reset_goals.requested_goal_ids.len(), + reset_goals.retired_goal_ids.len(), + reset_goals.already_absent_goal_ids.len(), + reset_goals.archived_goal_ids.len(), + reset_goals.missing_runtime_goal_ids.len() + ); + self.agent + .reset(LoopxAgentResetRequest { + operation_id: format!("reset-history-{}", uuid::Uuid::new_v4()), + }) + .await + .map_err(|error| error.to_string())?; + + let mut fresh = LoopxPersistedState::new(now_ms()); + fresh.environment = environment; + 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(); + *self.load_error.write().await = None; + } + 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() + }); + Ok(LoopxActionResponse { + current_revision: fresh.revision, + message: Some(format!( + "Cleared {} LoopX tasks, {} global goal routes, managed workspaces, runtime state, and persisted controller state", + tasks.len(), + reset_goals.retired_goal_ids.len() + + reset_goals.already_absent_goal_ids.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 is_autonomy_review = self + .is_autonomy_review_gate(&task.task_id, &gate_id) + .await; + 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()), + }); + } + self.record_goal_state(task, result.goal_state).await?; + self.mutate_task(&task.task_id, None, |current, current_runtime| { + if current.generation != task.generation { + return; + } + current.autonomous_turns_since_review = 0; + current.autonomy_review_baseline_receipts = result.settlement_receipt_count; + current.revision = current.revision.saturating_add(1); + current_runtime.expected_durable_revision = Some(result.durable_revision.clone()); + }) + .await?; + let stop_after_reject = is_autonomy_review + && request.action == LoopxActionKind::Reject; + let response = self + .transition_action( + &task.task_id, + if stop_after_reject { + LoopxTaskState::Stopped + } else { + LoopxTaskState::Queued + }, + if stop_after_reject { + LoopxPhase::Finished + } else { + LoopxPhase::Queued + }, + &request.client_request_id, + ) + .await?; + self.release_repository(task).await; + if stop_after_reject { + if let Some(updated) = response.task.as_ref() { + self.append_task_event( + updated, + LoopxEventKind::StateChanged, + "Owner stopped autonomous investigation at the review gate", + true, + ) + .await?; + } + self.schedule_next_for_repository( + &task.identity.item.repository.canonical_id(), + Some(&task.task_id), + ) + .await; + } else { + self.enqueue_task(task.task_id.clone(), Duration::ZERO)?; + } + Ok(response) + } + + async fn require_autonomy_review( + self: &Arc, + task: &LoopxTaskSnapshot, + runtime: &LoopxTaskRuntimeRecord, + autonomous_turns: u32, + ) -> Result<(), String> { + let agent_id = task + .agent_id + .clone() + .unwrap_or_else(|| DEFAULT_AGENT_ID.to_string()); + let message = format!( + "LoopX autonomy review checkpoint {}: completed {} autonomous turns without completing this Issue. Approve another bounded investigation segment, optionally authorize a preventive fix in the approval note, or reject to stop while preserving the worktree and evidence.", + task.revision, + autonomous_turns, + ); + let mut context = goal_context(task, runtime); + context.call.operation_id = format!( + "autonomy-review-{}-{}", + task.task_id, + uuid::Uuid::new_v4() + ); + let progress = BufferedProgress::default(); + let gate = self + .cli + .create_user_gate( + LoopxCliCreateUserGateRequest { + context, + goal_id: task.goal_id.clone().unwrap_or_default(), + agent_id, + message: message.clone(), + action_kind: AUTONOMY_REVIEW_ACTION_KIND.to_string(), + }, + &progress, + ) + .await + .map_err(|error| error.to_string())?; + self.record_progress(progress.take()).await?; + let updated = self + .mutate_task(&task.task_id, None, |current, current_runtime| { + if current.generation != task.generation { + return; + } + current.state = LoopxTaskState::WaitingForUser; + current.phase = LoopxPhase::WaitingForApproval; + current.goal_state = Some(LoopxCliGoalState::WaitingForUser); + current.pending_gate_id = Some(gate.gate_id.clone()); + current.pending_gate_message = Some(message.clone()); + current.pending_gate_action_kind = + Some(AUTONOMY_REVIEW_ACTION_KIND.to_string()); + current.current_turn_id = None; + current.current_tool = None; + current.deadline_at = None; + current.autonomous_turns_since_review = 0; + current.autonomy_review_baseline_receipts = gate.settlement_receipt_count; + current.revision = current.revision.saturating_add(1); + current_runtime.expected_durable_revision = Some(gate.durable_revision.clone()); + }) + .await?; + let mut details = BTreeMap::new(); + details.insert("gateId".to_string(), gate.gate_id); + details.insert( + "actionKind".to_string(), + AUTONOMY_REVIEW_ACTION_KIND.to_string(), + ); + details.insert( + "autonomousTurns".to_string(), + autonomous_turns.to_string(), + ); + self.append_task_event_with_details( + &updated, + LoopxEventKind::ApprovalRequired, + &message, + true, + details, + ) + .await?; + self.release_repository(task).await; + self.schedule_next_for_repository( + &task.identity.item.repository.canonical_id(), + Some(&task.task_id), + ) + .await; + Ok(()) + } + + async fn is_autonomy_review_gate(&self, task_id: &str, gate_id: &str) -> bool { + self.state.read().await.events.iter().rev().any(|event| { + event.task_id.as_deref() == Some(task_id) + && event.kind == LoopxEventKind::ApprovalRequired + && event.details.get("gateId").map(String::as_str) == Some(gate_id) + && event.details.get("actionKind").map(String::as_str) + == Some(AUTONOMY_REVIEW_ACTION_KIND) + }) + } + + 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.goal_state = Some(match settlement.status { + LoopxCliSettlementStatus::GoalCompleted => LoopxCliGoalState::Completed, + _ => LoopxCliGoalState::Active, + }); + task.revision = task.revision.saturating_add(1); + task.current_turn_id = None; + task.pending_gate_id = None; + task.pending_gate_message = None; + task.pending_gate_action_kind = 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()), + }; + if final_state == LoopxTaskState::Queued { + task.autonomous_turns_since_review = task + .autonomous_turns_since_review + .saturating_add(1); + } else if final_state == LoopxTaskState::Completed { + task.autonomous_turns_since_review = 0; + } + runtime.session_id = None; + runtime.agent_turn_id = None; + runtime.expected_durable_revision = Some(settlement.after_revision.clone()); + }) + .await?; + log::info!( + "LoopX task settlement applied: task_id={}, goal_id={}, final_state={:?}, phase={:?}, settlement_status={:?}", + updated.task_id, + updated.goal_id.as_deref().unwrap_or("unknown"), + updated.state, + updated.phase, + settlement.status + ); + self.release_repository(&updated).await; + let mut yielded_repository = false; + 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?; + yielded_repository = self + .schedule_next_for_repository( + &updated.identity.item.repository.canonical_id(), + Some(&updated.task_id), + ) + .await; + if yielded_repository { + self.suppress_pending_task_rerun(&updated.task_id).await; + } + } + if should_requeue_after_settlement(final_state, yielded_repository) { + 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.goal_state = Some(LoopxCliGoalState::Active); + 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 record_goal_state( + &self, + task: &LoopxTaskSnapshot, + goal_state: LoopxCliGoalState, + ) -> Result<(), String> { + if task.goal_state == Some(goal_state) { + return Ok(()); + } + self.mutate_task(&task.task_id, None, |current, _| { + if current.generation != task.generation { + return; + } + current.goal_state = Some(goal_state); + current.revision = current.revision.saturating_add(1); + }) + .await + .map(|_| ()) + } + + async fn apply_goal_projection( + &self, + expected: &LoopxTaskSnapshot, + goal: &LoopxCliGoalSnapshot, + ) -> Result<(), String> { + let projection = project_host_task_from_goal(expected.state, expected.phase, goal.state); + let preserve_pending_gate = preserve_unanswered_local_gate(expected, goal); + let pending_gate_id = if preserve_pending_gate { + expected.pending_gate_id.as_deref() + } else { + goal.pending_user_gate + .as_ref() + .map(|gate| gate.gate_id.as_str()) + }; + let pending_gate_message = if preserve_pending_gate { + expected.pending_gate_message.as_deref() + } else { + goal.pending_user_gate + .as_ref() + .map(|gate| gate.message.as_str()) + }; + let pending_gate_action_kind = if preserve_pending_gate { + expected.pending_gate_action_kind.as_deref() + } else { + goal.pending_user_gate + .as_ref() + .and_then(|gate| gate.action_kind.as_deref()) + }; + if expected.goal_state == Some(goal.state) + && expected.state == projection.state + && expected.phase == projection.phase + && expected.pending_gate_id.as_deref() == pending_gate_id + && expected.pending_gate_message.as_deref() == pending_gate_message + && expected.pending_gate_action_kind.as_deref() == pending_gate_action_kind + { + return Ok(()); + } + + let host_state_changed = expected.state != projection.state; + let updated = self + .mutate_task(&expected.task_id, None, |task, runtime| { + if task.generation != expected.generation { + return; + } + let preserve_pending_gate = preserve_unanswered_local_gate(task, goal); + let current = project_host_task_from_goal(task.state, task.phase, goal.state); + task.goal_state = Some(goal.state); + task.state = current.state; + task.phase = current.phase; + if !preserve_pending_gate { + task.pending_gate_id = goal + .pending_user_gate + .as_ref() + .map(|gate| gate.gate_id.clone()); + task.pending_gate_message = goal + .pending_user_gate + .as_ref() + .map(|gate| gate.message.clone()); + task.pending_gate_action_kind = goal + .pending_user_gate + .as_ref() + .and_then(|gate| gate.action_kind.clone()); + } + task.revision = task.revision.saturating_add(1); + runtime.expected_durable_revision = Some(goal.durable_revision.clone()); + if task.state.is_terminal() { + task.current_turn_id = None; + task.current_tool = None; + task.deadline_at = None; + task.retry_at = None; + } + }) + .await?; + + if host_state_changed { + self.append_task_event( + &updated, + LoopxEventKind::SnapshotInvalidated, + "BitFun host task reconciled with authoritative LoopX Goal state", + false, + ) + .await?; + if updated.state == LoopxTaskState::Queued { + self.enqueue_task(updated.task_id.clone(), Duration::ZERO)?; + } + } + Ok(()) + } + + 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; + if state != LoopxTaskState::WaitingForUser { + task.pending_gate_id = None; + task.pending_gate_message = None; + task.pending_gate_action_kind = None; + } + 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; + if state != LoopxTaskState::WaitingForUser { + task.pending_gate_id = None; + task.pending_gate_message = None; + task.pending_gate_action_kind = None; + } + if state == LoopxTaskState::Queued { + task.autonomous_turns_since_review = 0; + } + 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.pending_gate_id = None; + task.pending_gate_message = None; + task.pending_gate_action_kind = None; + 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>, + ) -> bool { + 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 { + self.enqueue_task(task_id, Duration::ZERO).is_ok() + } else { + false + } + } + + 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 suppress_pending_task_rerun(&self, task_id: &str) { + if let Some(pending) = self.active_tasks.lock().await.get_mut(task_id) { + *pending = 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::Stopped | LoopxTaskState::Failed | LoopxTaskState::RecoveryRequired + ) +} + +fn should_requeue_after_settlement(final_state: LoopxTaskState, yielded_repository: bool) -> bool { + final_state == LoopxTaskState::Queued && !yielded_repository +} + +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 bounded_agent_summary(summary: &str) -> String { + let mut chars = summary.chars(); + let bounded = chars + .by_ref() + .take(MAX_AGENT_SUMMARY_CHARS) + .collect::(); + if chars.next().is_some() { + format!("{bounded}\n\n[Summary truncated by LoopX host]") + } else { + bounded + } +} + +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() + } +} + +fn autonomous_turns_since_review( + task: &LoopxTaskSnapshot, + settlement_receipt_count: u32, +) -> u32 { + task.autonomous_turns_since_review.max( + settlement_receipt_count.saturating_sub(task.autonomy_review_baseline_receipts), + ) +} + +fn autonomy_review_required(autonomous_turns: u32) -> bool { + autonomous_turns >= AUTONOMOUS_TURN_BUDGET +} + +/// Reconciliation may replace a local gate with a durable gate projection, but +/// it must never infer approval from an active Goal. Only an explicit gate +/// answer transitions the host task away from WaitingForUser. +fn preserve_unanswered_local_gate( + task: &LoopxTaskSnapshot, + goal: &LoopxCliGoalSnapshot, +) -> bool { + task.state == LoopxTaskState::WaitingForUser + && task.pending_gate_id.is_some() + && goal.pending_user_gate.is_none() + && !matches!( + goal.state, + LoopxCliGoalState::Completed + | LoopxCliGoalState::Failed + | LoopxCliGoalState::Archived + ) +} + +#[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_includes_all_resumable_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 agent_summary_projection_is_bounded_on_character_boundaries() { + let summary = "界".repeat(MAX_AGENT_SUMMARY_CHARS + 1); + let bounded = bounded_agent_summary(&summary); + + assert!(bounded.ends_with("[Summary truncated by LoopX host]")); + assert_eq!(bounded.matches('界').count(), MAX_AGENT_SUMMARY_CHARS); + } + + #[test] + fn settled_task_does_not_self_requeue_after_yielding_repository() { + assert!(!should_requeue_after_settlement( + LoopxTaskState::Queued, + true, + )); + assert!(should_requeue_after_settlement( + LoopxTaskState::Queued, + false, + )); + assert!(!should_requeue_after_settlement( + LoopxTaskState::Completed, + false, + )); + } + + #[test] + fn autonomous_turn_budget_requires_an_owner_review_at_the_boundary() { + let below = LoopxTaskSnapshot { + autonomous_turns_since_review: AUTONOMOUS_TURN_BUDGET - 1, + ..LoopxTaskSnapshot::default() + }; + let reached = LoopxTaskSnapshot { + autonomous_turns_since_review: AUTONOMOUS_TURN_BUDGET, + ..LoopxTaskSnapshot::default() + }; + + assert!(!autonomy_review_required(autonomous_turns_since_review( + &below, + AUTONOMOUS_TURN_BUDGET - 1, + ))); + assert!(autonomy_review_required(autonomous_turns_since_review( + &reached, + AUTONOMOUS_TURN_BUDGET, + ))); + + let legacy = LoopxTaskSnapshot::default(); + assert_eq!( + autonomous_turns_since_review(&legacy, AUTONOMOUS_TURN_BUDGET + 2), + AUTONOMOUS_TURN_BUDGET + 2, + ); + } + + #[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() + })); + } + + #[test] + fn reconciliation_cannot_clear_an_unanswered_local_gate() { + let task = LoopxTaskSnapshot { + state: LoopxTaskState::WaitingForUser, + phase: LoopxPhase::WaitingForApproval, + pending_gate_id: Some("todo_owner_review".to_string()), + ..LoopxTaskSnapshot::default() + }; + let active_goal = LoopxCliGoalSnapshot { + state: LoopxCliGoalState::Active, + ..LoopxCliGoalSnapshot::default() + }; + assert!(preserve_unanswered_local_gate(&task, &active_goal)); + + let answered_task = LoopxTaskSnapshot { + state: LoopxTaskState::Queued, + ..task.clone() + }; + assert!(!preserve_unanswered_local_gate( + &answered_task, + &active_goal, + )); + + let completed_goal = LoopxCliGoalSnapshot { + state: LoopxCliGoalState::Completed, + ..active_goal + }; + assert!(!preserve_unanswered_local_gate(&task, &completed_goal)); + } +} 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..7ba2797640 --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/mod.rs @@ -0,0 +1,10 @@ +mod agent_adapter; +mod controller; +mod store; +mod subscriber; +mod tool_activity; + +pub use agent_adapter::CoreLoopxAgentPort; +pub use controller::LoopxController; +pub use store::{LoopxPersistedState, LoopxStateStore, LoopxTaskRuntimeRecord}; +pub use subscriber::LoopxEventSubscriber; 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..ad1e95ff61 --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/store.rs @@ -0,0 +1,316 @@ +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, + /// Durable BitFun host jobs and the last read-only LoopX Goal projection. + /// LoopX registry state remains authoritative for Goal lifecycle facts. + 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(), + } + } + + 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::{LoopxTaskSnapshot, LoopxTaskState}; + + #[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..29d95dda8c --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/subscriber.rs @@ -0,0 +1,401 @@ +use super::tool_activity::project_tool_activity; +use super::LoopxController; +use crate::agentic::events::{AgenticEvent, EventSubscriber}; +use bitfun_agent_runtime::event_bus::{EventBusError, EventSubscriberResult}; +use bitfun_core_types::errors::{AiErrorDetail, ErrorCategory}; +use bitfun_product_domains::miniapp::loopx::LoopxAgentTurnStatus; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +const ACTIVITY_PERSIST_INTERVAL: Duration = Duration::from_secs(2); +const MAX_FINAL_RESPONSE_CHARS: usize = 16_000; + +pub struct LoopxEventSubscriber { + controller: Arc, + activity: ActivityGate, +} + +impl LoopxEventSubscriber { + pub fn new(controller: Arc) -> Self { + Self { + controller, + activity: ActivityGate::default(), + } + } +} + +#[derive(Default)] +struct ActivityGate(Mutex>); + +#[derive(Default)] +struct TurnActivity { + last_persisted: Option, + stream_events: u64, + suppressed_tool_events: u64, + tool_lifecycle_events: u64, + persisted_checkpoints: u64, + latest_round_id: Option, + latest_attempt_id: Option, + latest_round_text: String, +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct ActivitySummary { + stream_events: u64, + suppressed_tool_events: u64, + tool_lifecycle_events: u64, + persisted_checkpoints: u64, + final_response: Option, +} + +impl ActivityGate { + fn start_round(&self, turn_id: &str, round_id: &str) { + let mut turns = self + .0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let activity = turns.entry(turn_id.to_string()).or_default(); + if activity.latest_round_id.as_deref() != Some(round_id) { + activity.latest_round_id = Some(round_id.to_string()); + activity.latest_attempt_id = None; + activity.latest_round_text.clear(); + } + } + + fn record_text(&self, turn_id: &str, round_id: &str, attempt_id: Option<&str>, text: &str) { + let mut turns = self + .0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let activity = turns.entry(turn_id.to_string()).or_default(); + let round_changed = activity.latest_round_id.as_deref() != Some(round_id); + let attempt_changed = !round_changed + && attempt_id.is_some_and(|incoming| { + activity + .latest_attempt_id + .as_deref() + .is_some_and(|current| current != incoming) + }); + if round_changed || attempt_changed { + activity.latest_round_id = Some(round_id.to_string()); + activity.latest_attempt_id = attempt_id.map(str::to_string); + activity.latest_round_text.clear(); + } else if activity.latest_attempt_id.is_none() { + activity.latest_attempt_id = attempt_id.map(str::to_string); + } + append_bounded_text( + &mut activity.latest_round_text, + text, + MAX_FINAL_RESPONSE_CHARS, + ); + } + + fn record_stream(&self, turn_id: &str, force: bool) -> bool { + self.record_stream_at(turn_id, Instant::now(), force) + } + + fn record_stream_at(&self, turn_id: &str, now: Instant, force: bool) -> bool { + let mut turns = self + .0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let activity = turns.entry(turn_id.to_string()).or_default(); + activity.stream_events = activity.stream_events.saturating_add(1); + let should_persist = force + || activity + .last_persisted + .is_none_or(|last| now.duration_since(last) >= ACTIVITY_PERSIST_INTERVAL); + if should_persist { + activity.last_persisted = Some(now); + activity.persisted_checkpoints = activity.persisted_checkpoints.saturating_add(1); + } + should_persist + } + + fn record_suppressed_tool_event(&self, turn_id: &str) -> bool { + let now = Instant::now(); + let mut turns = self + .0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let activity = turns.entry(turn_id.to_string()).or_default(); + activity.suppressed_tool_events = activity.suppressed_tool_events.saturating_add(1); + let should_persist = activity + .last_persisted + .is_none_or(|last| now.duration_since(last) >= ACTIVITY_PERSIST_INTERVAL); + if should_persist { + activity.last_persisted = Some(now); + activity.persisted_checkpoints = activity.persisted_checkpoints.saturating_add(1); + } + should_persist + } + + fn record_tool_lifecycle(&self, turn_id: &str) { + let mut turns = self + .0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let activity = turns.entry(turn_id.to_string()).or_default(); + activity.last_persisted = Some(Instant::now()); + activity.tool_lifecycle_events = activity.tool_lifecycle_events.saturating_add(1); + activity.persisted_checkpoints = activity.persisted_checkpoints.saturating_add(1); + } + + fn finish(&self, turn_id: &str) -> Option { + self.0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(turn_id) + .map(|activity| ActivitySummary { + stream_events: activity.stream_events, + suppressed_tool_events: activity.suppressed_tool_events, + tool_lifecycle_events: activity.tool_lifecycle_events, + persisted_checkpoints: activity.persisted_checkpoints, + final_response: (!activity.latest_round_text.trim().is_empty()) + .then(|| activity.latest_round_text.trim().to_string()), + }) + } +} + +impl LoopxEventSubscriber { + fn finish_activity(&self, turn_id: &str) -> Option { + let Some(summary) = self.activity.finish(turn_id) else { + return None; + }; + log::info!( + "LoopX Agent event projection summary: turn_id={}, stream_events={}, suppressed_tool_events={}, tool_lifecycle_events={}, persisted_checkpoints={}", + turn_id, + summary.stream_events, + summary.suppressed_tool_events, + summary.tool_lifecycle_events, + summary.persisted_checkpoints + ); + Some(summary) + } +} + +#[async_trait::async_trait] +impl EventSubscriber for LoopxEventSubscriber { + async fn on_event(&self, event: &AgenticEvent) -> EventSubscriberResult { + let result = match event { + AgenticEvent::TextChunk { + turn_id, + round_id, + attempt_id, + text, + .. + } => { + self.activity + .record_text(turn_id, round_id, attempt_id.as_deref(), text); + if self.activity.record_stream(turn_id, false) { + self.controller.handle_agent_activity(turn_id).await + } else { + Ok(()) + } + } + AgenticEvent::ThinkingChunk { turn_id, .. } => { + if self.activity.record_stream(turn_id, false) { + self.controller.handle_agent_activity(turn_id).await + } else { + Ok(()) + } + } + AgenticEvent::ModelRoundStarted { + turn_id, round_id, .. + } => { + self.activity.start_round(turn_id, round_id); + if self.activity.record_stream(turn_id, true) { + self.controller.handle_agent_activity(turn_id).await + } else { + Ok(()) + } + } + AgenticEvent::ToolEvent { + turn_id, + tool_event, + .. + } => { + if let Some(activity) = project_tool_activity(tool_event) { + self.activity.record_tool_lifecycle(turn_id); + self.controller + .handle_agent_tool_activity(turn_id, activity) + .await + } else if self.activity.record_suppressed_tool_event(turn_id) { + self.controller.handle_agent_activity(turn_id).await + } else { + Ok(()) + } + } + AgenticEvent::DialogTurnCompleted { + turn_id, + success, + has_final_response, + .. + } => { + let summary = self.finish_activity(turn_id).and_then(|summary| { + (has_final_response.unwrap_or(true)) + .then_some(summary.final_response) + .flatten() + }); + let status = if *success == Some(false) { + LoopxAgentTurnStatus::Failed + } else { + LoopxAgentTurnStatus::Completed + }; + self.controller + .handle_agent_terminal(turn_id, status, summary, false) + .await + } + AgenticEvent::DialogTurnFailed { + turn_id, + error, + error_category, + error_detail, + .. + } => { + let _ = self.finish_activity(turn_id); + 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, .. } => { + let _ = self.finish_activity(turn_id); + self.controller + .handle_agent_terminal(turn_id, LoopxAgentTurnStatus::Cancelled, None, false) + .await + } + AgenticEvent::DialogTurnInterrupted { turn_id, .. } => { + let _ = self.finish_activity(turn_id); + self.controller + .handle_agent_terminal(turn_id, LoopxAgentTurnStatus::Interrupted, None, false) + .await + } + _ => Ok(()), + }; + result.map_err(EventBusError::subscriber) + } +} + +fn append_bounded_text(buffer: &mut String, text: &str, max_chars: usize) { + let remaining = max_chars.saturating_sub(buffer.chars().count()); + if remaining > 0 { + buffer.extend(text.chars().take(remaining)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn activity_gate_coalesces_stream_chunks_but_allows_forced_boundaries() { + let gate = ActivityGate::default(); + let now = Instant::now(); + + assert!(gate.record_stream_at("turn-1", now, false)); + assert!(!gate.record_stream_at("turn-1", now + Duration::from_millis(500), false)); + assert!(gate.record_stream_at("turn-1", now + Duration::from_millis(600), true)); + assert!(!gate.record_stream_at("turn-1", now + Duration::from_secs(1), false)); + assert!(gate.record_stream_at("turn-1", now + Duration::from_secs(3), false)); + } + + #[test] + fn clearing_activity_allows_the_next_generation_to_persist_immediately() { + let gate = ActivityGate::default(); + let now = Instant::now(); + assert!(gate.record_stream_at("turn-1", now, false)); + let summary = gate.finish("turn-1").expect("activity summary"); + assert_eq!(summary.stream_events, 1); + assert!(gate.record_stream_at("turn-1", now + Duration::from_millis(1), false)); + } + + #[test] + fn stream_flood_produces_one_liveness_checkpoint_inside_the_interval() { + let gate = ActivityGate::default(); + let now = Instant::now(); + let checkpoints = (0..10_000) + .filter(|_| gate.record_stream_at("turn-1", now, false)) + .count(); + + assert_eq!(checkpoints, 1); + assert_eq!( + gate.finish("turn-1").expect("activity summary"), + ActivitySummary { + stream_events: 10_000, + persisted_checkpoints: 1, + ..ActivitySummary::default() + } + ); + } + + #[test] + fn final_response_uses_only_the_latest_model_round() { + let gate = ActivityGate::default(); + gate.start_round("turn-1", "round-1"); + gate.record_text("turn-1", "round-1", Some("attempt-1"), "intermediate"); + gate.start_round("turn-1", "round-2"); + gate.record_text("turn-1", "round-2", Some("attempt-1"), "final "); + gate.record_text("turn-1", "round-2", Some("attempt-1"), "answer"); + + assert_eq!( + gate.finish("turn-1") + .and_then(|summary| summary.final_response), + Some("final answer".to_string()) + ); + } + + #[test] + fn superseded_attempt_does_not_leak_partial_text() { + let gate = ActivityGate::default(); + gate.start_round("turn-1", "round-1"); + gate.record_text("turn-1", "round-1", Some("attempt-1"), "partial"); + gate.record_text("turn-1", "round-1", Some("attempt-2"), "retry answer"); + + assert_eq!( + gate.finish("turn-1") + .and_then(|summary| summary.final_response), + Some("retry answer".to_string()) + ); + } +} + +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/loopx/tool_activity.rs b/src/crates/assembly/core/src/miniapp/loopx/tool_activity.rs new file mode 100644 index 0000000000..4dad331aea --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/tool_activity.rs @@ -0,0 +1,313 @@ +use bitfun_events::ToolEventData; +use bitfun_services_core::session_usage::redaction::redact_usage_input_summary; +use serde_json::Value; +use std::collections::BTreeMap; + +const MAX_TOOL_SUMMARY_CHARS: usize = 240; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ToolActivityProjection { + pub tool_name: String, + pub state: &'static str, + pub message: String, + pub details: BTreeMap, + pub current_tool: Option, + pub important: bool, +} + +pub(super) fn project_tool_activity(event: &ToolEventData) -> Option { + let tool_name = event.effective_tool_name().to_string(); + let mut details = BTreeMap::from([ + ("activity".to_string(), activity_state(event)?.to_string()), + ("toolName".to_string(), tool_name.clone()), + ]); + let (message, current_tool, important) = match event { + ToolEventData::Queued { position, .. } => { + details.insert("queuePosition".to_string(), position.to_string()); + ( + format!("Tool queued: {tool_name}"), + Some(tool_name.clone()), + false, + ) + } + ToolEventData::Waiting { dependencies, .. } => { + if !dependencies.is_empty() { + details.insert("dependencies".to_string(), dependencies.join(", ")); + } + ( + format!("Tool waiting: {tool_name}"), + Some(tool_name.clone()), + false, + ) + } + ToolEventData::Started { params, .. } => { + if let Some(summary) = tool_input_summary(&tool_name, params) { + details.insert("summary".to_string(), summary); + } + ( + format!("Tool started: {tool_name}"), + Some(tool_name.clone()), + false, + ) + } + ToolEventData::ConfirmationNeeded { params, .. } => { + if let Some(summary) = tool_input_summary(&tool_name, params) { + details.insert("summary".to_string(), summary); + } + ( + format!("Tool needs confirmation: {tool_name}"), + Some(tool_name.clone()), + false, + ) + } + ToolEventData::Confirmed { .. } => ( + format!("Tool confirmed: {tool_name}"), + Some(tool_name.clone()), + false, + ), + ToolEventData::Rejected { .. } => (format!("Tool rejected: {tool_name}"), None, true), + ToolEventData::Completed { + result, + duration_ms, + .. + } => { + details.insert("durationMs".to_string(), duration_ms.to_string()); + insert_result_facts(&tool_name, result, &mut details); + (format!("Tool completed: {tool_name}"), None, false) + } + ToolEventData::Failed { + error, duration_ms, .. + } => { + if let Some(duration_ms) = duration_ms { + details.insert("durationMs".to_string(), duration_ms.to_string()); + } + details.insert( + "summary".to_string(), + redact_usage_input_summary(error, MAX_TOOL_SUMMARY_CHARS).value, + ); + (format!("Tool failed: {tool_name}"), None, true) + } + ToolEventData::Cancelled { + reason, + duration_ms, + .. + } => { + if let Some(duration_ms) = duration_ms { + details.insert("durationMs".to_string(), duration_ms.to_string()); + } + details.insert( + "summary".to_string(), + redact_usage_input_summary(reason, MAX_TOOL_SUMMARY_CHARS).value, + ); + (format!("Tool cancelled: {tool_name}"), None, false) + } + ToolEventData::EarlyDetected { .. } + | ToolEventData::ParamsPartial { .. } + | ToolEventData::Progress { .. } + | ToolEventData::Streaming { .. } + | ToolEventData::StreamChunk { .. } => return None, + }; + + Some(ToolActivityProjection { + tool_name, + state: activity_state(event).expect("projected tool events have a stable state"), + message, + details, + current_tool, + important, + }) +} + +fn activity_state(event: &ToolEventData) -> Option<&'static str> { + match event { + ToolEventData::Queued { .. } => Some("queued"), + ToolEventData::Waiting { .. } => Some("waiting"), + ToolEventData::Started { .. } => Some("started"), + ToolEventData::ConfirmationNeeded { .. } => Some("confirmation"), + ToolEventData::Confirmed { .. } => Some("confirmed"), + ToolEventData::Rejected { .. } => Some("rejected"), + ToolEventData::Completed { .. } => Some("completed"), + ToolEventData::Failed { .. } => Some("failed"), + ToolEventData::Cancelled { .. } => Some("cancelled"), + ToolEventData::EarlyDetected { .. } + | ToolEventData::ParamsPartial { .. } + | ToolEventData::Progress { .. } + | ToolEventData::Streaming { .. } + | ToolEventData::StreamChunk { .. } => None, + } +} + +fn tool_input_summary(tool_name: &str, params: &Value) -> Option { + let fields = params.as_object()?; + let value = match tool_name { + "ExecCommand" => string_field(fields, &["cmd", "command"]), + "Read" => string_field(fields, &["file_path", "path"]), + "Write" => string_field( + fields, + &["file_path", "filePath", "path", "target_path", "targetPath"], + ) + .or_else(|| { + string_field(fields, &["payload"]).and_then(|value| write_payload_path(&value)) + }), + "Edit" => string_field( + fields, + &["file_path", "filePath", "path", "target_path", "targetPath"], + ), + "LS" => string_field(fields, &["path"]), + "WebFetch" => string_field(fields, &["url", "request_url"]), + "Grep" => { + let pattern = string_field(fields, &["pattern"])?; + match string_field(fields, &["path"]) { + Some(path) => Some(format!("{pattern} in {path}")), + None => Some(pattern), + } + } + _ => None, + }?; + Some(redact_usage_input_summary(&value, MAX_TOOL_SUMMARY_CHARS).value) +} + +fn write_payload_path(payload: &str) -> Option { + payload + .lines() + .next()? + .trim() + .strip_prefix("+++") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.trim_matches(|ch| ch == '{' || ch == '}').to_string()) +} + +fn string_field(fields: &serde_json::Map, names: &[&str]) -> Option { + names + .iter() + .find_map(|name| fields.get(*name).and_then(Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn insert_result_facts(tool_name: &str, result: &Value, details: &mut BTreeMap) { + let Some(fields) = result.as_object() else { + return; + }; + let facts: &[(&str, &[&str])] = match tool_name { + "ExecCommand" => &[("exitCode", &["exit_code", "exitCode"])], + "Grep" => &[ + ("matchCount", &["total_matches", "totalMatches"]), + ("fileCount", &["file_count", "fileCount"]), + ], + "LS" => &[("entryCount", &["total"])], + "Read" => &[("lineCount", &["lines_read", "linesRead"])], + "WebFetch" => &[("contentLength", &["content_length", "contentLength"])], + _ => &[], + }; + for (detail_name, field_names) in facts { + if let Some(value) = field_names + .iter() + .find_map(|field_name| fields.get(*field_name)) + .and_then(json_scalar_label) + { + details.insert((*detail_name).to_string(), value); + } + } + if tool_name == "WebFetch" { + if let Some(title) = string_field(fields, &["title"]) { + details.insert( + "title".to_string(), + redact_usage_input_summary(&title, MAX_TOOL_SUMMARY_CHARS).value, + ); + } + } +} + +fn json_scalar_label(value: &Value) -> Option { + match value { + Value::String(value) => Some(value.clone()), + Value::Number(value) => Some(value.to_string()), + Value::Bool(value) => Some(value.to_string()), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_events::ToolEventIdentity; + + #[test] + fn partial_parameters_are_not_projected_as_user_log_events() { + let event = ToolEventData::ParamsPartial { + identity: ToolEventIdentity::direct("tool-1", "ExecCommand"), + params: "{\"cmd\":\"cargo".to_string(), + }; + + assert!(project_tool_activity(&event).is_none()); + } + + #[test] + fn command_summary_is_bounded_and_redacts_secrets() { + let event = ToolEventData::Started { + identity: ToolEventIdentity::direct("tool-1", "ExecCommand"), + params: serde_json::json!({ + "cmd": "curl --api-key secret-value https://example.test" + }), + timeout_seconds: None, + }; + + let projection = project_tool_activity(&event).expect("started tool is projected"); + let summary = projection.details.get("summary").expect("summary"); + assert!(summary.contains("--api-key [redacted]")); + assert!(!summary.contains("secret-value")); + } + + #[test] + fn completed_tool_projects_small_result_facts() { + let event = ToolEventData::Completed { + identity: ToolEventIdentity::direct("tool-1", "Grep"), + result: serde_json::json!({ "total_matches": 12, "file_count": 3 }), + result_for_assistant: None, + image_attachments: None, + duration_ms: 42, + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: Some(42), + }; + + let projection = project_tool_activity(&event).expect("completed tool is projected"); + assert_eq!( + projection.details.get("matchCount").map(String::as_str), + Some("12") + ); + assert_eq!( + projection.details.get("fileCount").map(String::as_str), + Some("3") + ); + assert_eq!( + projection.details.get("durationMs").map(String::as_str), + Some("42") + ); + } + + #[test] + fn write_activity_projects_only_the_target_path() { + let event = ToolEventData::Started { + identity: ToolEventIdentity::direct("tool-1", "Write"), + params: serde_json::json!({ + "payload": "+++ src/window/focus.ts\nprivate implementation details" + }), + timeout_seconds: None, + }; + + let projection = project_tool_activity(&event).expect("started write is projected"); + assert_eq!( + projection.details.get("summary").map(String::as_str), + Some("src/window/focus.ts") + ); + assert!(!projection + .details + .values() + .any(|value| value.contains("private implementation details"))); + } +} 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/config/types.rs b/src/crates/assembly/core/src/service/config/types.rs index 038a8aff24..1f676abb5f 100644 --- a/src/crates/assembly/core/src/service/config/types.rs +++ b/src/crates/assembly/core/src/service/config/types.rs @@ -25,6 +25,65 @@ where .collect()) } +fn deserialize_optional_string_map<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + let raw = Option::::deserialize(deserializer)?; + match raw { + None | Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::Object(fields)) => { + let mut headers = HashMap::with_capacity(fields.len()); + for (name, value) in fields { + let serde_json::Value::String(header_value) = value else { + return Err(::custom( + "custom_headers values must be strings", + )); + }; + headers.insert(name, header_value); + } + Ok(Some(headers)) + } + Some(serde_json::Value::String(value)) if value.trim().is_empty() => Ok(None), + Some(_) => Err(::custom( + "custom_headers must be an object or an empty string", + )), + } +} + +fn deserialize_datetime_millis_or_rfc3339<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum CompatibleTimestamp { + Milliseconds(i64), + Rfc3339(String), + } + + match CompatibleTimestamp::deserialize(deserializer)? { + CompatibleTimestamp::Milliseconds(value) => { + chrono::DateTime::::from_timestamp_millis(value).ok_or_else(|| { + ::custom(format!( + "last_modified timestamp is out of range: {value}" + )) + }) + } + CompatibleTimestamp::Rfc3339(value) => chrono::DateTime::parse_from_rfc3339(&value) + .map(|timestamp| timestamp.with_timezone(&chrono::Utc)) + .map_err(|error| { + ::custom(format!( + "last_modified must be Unix milliseconds or RFC3339: {error}" + )) + }), + } +} + /// Web UI font preferences (settings → basics). Keys match `FontPreference` in the frontend (camelCase). #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -82,7 +141,10 @@ pub struct GlobalConfig { #[serde(default = "default_config_schema_version")] pub schema_version: u32, pub version: String, - #[serde(with = "chrono::serde::ts_milliseconds")] + #[serde( + serialize_with = "chrono::serde::ts_milliseconds::serialize", + deserialize_with = "deserialize_datetime_millis_or_rfc3339" + )] pub last_modified: chrono::DateTime, } @@ -1584,6 +1646,7 @@ struct AIModelConfigCompat { reasoning: Option, #[serde(default = "default_true")] inline_think_in_text: bool, + #[serde(default, deserialize_with = "deserialize_optional_string_map")] custom_headers: Option>, custom_headers_mode: Option, skip_ssl_verify: bool, @@ -2276,6 +2339,25 @@ mod tests { assert_eq!(config.tool_permissions, ToolPermissionConfig::default()); } + #[test] + fn legacy_global_config_accepts_rfc3339_last_modified() { + let legacy_timestamp = "2026-08-26T11:24:58.7496147Z"; + let expected = chrono::DateTime::parse_from_rfc3339(legacy_timestamp) + .expect("fixture timestamp should be valid") + .with_timezone(&chrono::Utc); + let config: GlobalConfig = serde_json::from_value(serde_json::json!({ + "last_modified": legacy_timestamp + })) + .expect("legacy RFC3339 last_modified should deserialize"); + + assert_eq!(config.last_modified, expected); + let serialized = serde_json::to_value(config).expect("config should serialize"); + assert_eq!( + serialized["last_modified"], + serde_json::json!(expected.timestamp_millis()) + ); + } + #[test] fn user_tool_groups_default_to_version_one_without_persisted_groups() { let config: GlobalConfig = serde_json::from_value(serde_json::json!({})) @@ -2670,6 +2752,40 @@ mod tests { assert!(config.inline_think_in_text); } + #[test] + fn deserializes_empty_string_custom_headers_as_absent() { + let config: AIModelConfig = serde_json::from_value(serde_json::json!({ + "id": "model_1", + "name": "Provider", + "provider": "openai", + "model_name": "test-model", + "base_url": "https://example.com/v1", + "api_key": "key", + "enabled": true, + "custom_headers": "" + })) + .expect("legacy empty custom_headers should deserialize"); + + assert!(config.custom_headers.is_none()); + } + + #[test] + fn default_chat_category_supports_text_generation_without_capability_tags() { + let config: AIModelConfig = serde_json::from_value(serde_json::json!({ + "id": "model_1", + "name": "Provider", + "provider": "openai", + "model_name": "test-model", + "base_url": "https://example.com/v1", + "api_key": "key", + "enabled": true + })) + .expect("model without capability tags should deserialize"); + + assert!(config.capabilities.is_empty()); + assert!(config.supports_text_generation()); + } + #[test] fn default_ai_config_uses_generous_stream_timeouts() { let config = AIConfig::default(); 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..16db6b68b5 100644 --- a/src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs +++ b/src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs @@ -1,23 +1,68 @@ //! Bridge script builder — generate window.app Runtime Adapter (BitFun Hosted) for iframe. +use crate::miniapp::loopx::private_bridge_extension; use crate::miniapp::types::{EsmDep, MiniAppPermissions}; 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.*, deck.*, chat.*, clipboard.*, lifecycle, and events. Verified +/// built-in product surfaces may receive an additional private namespace; it +/// is not part of the public MiniApp API. pub fn build_bridge_script( app_id: &str, app_data_dir: &str, workspace_dir: &str, appearance_mode: &str, platform: &str, +) -> String { + build_bridge_script_internal( + app_id, + app_data_dir, + workspace_dir, + appearance_mode, + platform, + true, + ) +} + +/// Marketplace compilation always receives the public MiniApp API, even if an +/// imported package attempts to spoof a reserved built-in id. +pub fn build_market_bridge_script( + app_id: &str, + app_data_dir: &str, + workspace_dir: &str, + appearance_mode: &str, + platform: &str, +) -> String { + build_bridge_script_internal( + app_id, + app_data_dir, + workspace_dir, + appearance_mode, + platform, + false, + ) +} + +fn build_bridge_script_internal( + app_id: &str, + app_data_dir: &str, + workspace_dir: &str, + appearance_mode: &str, + platform: &str, + allow_private_builtin_extensions: bool, ) -> String { let app_id_esc = escape_js_str(app_id); let app_data_esc = escape_js_str(app_data_dir); let workspace_esc = escape_js_str(workspace_dir); let appearance_mode_esc = escape_js_str(appearance_mode); let platform_esc = escape_js_str(platform); + let private_builtin_extension = if allow_private_builtin_extensions { + private_bridge_extension(app_id).unwrap_or_default() + } else { + "" + }; format!( r#" @@ -131,6 +176,8 @@ pub fn build_bridge_script( offEvent: (fn) => app.off('agent:event', fn), }}, + {private_builtin_extension} + // 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. @@ -260,7 +307,8 @@ pub fn build_bridge_script( app_data_esc = app_data_esc, workspace_esc = workspace_esc, appearance_mode_esc = appearance_mode_esc, - platform_esc = platform_esc + platform_esc = platform_esc, + private_builtin_extension = private_builtin_extension, ) } diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin.rs b/src/crates/contracts/product-domains/src/miniapp/builtin.rs index 693aec8c4d..e0557743fe 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: 14, + 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,63 @@ 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 +719,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..2c3352fc50 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/AGENTS.md @@ -0,0 +1,154 @@ +# 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 / 契约测试、`cargo check`、`pnpm run type-check:web` 等。只有用户 + **明确要求检查或测试**时才运行;用户只说“编译”代表直接产出可运行 exe,不包含预检查。 + - 允许的秒级自检:`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 build -p bitfun-desktop --bin bitfun-desktop`(统一配方,见 README + 「统一构建配方」,勿混用不同 profile 环境变量)→ 重启应用。最终 build 本身就是 + Rust 编译验证,不要在它前面重复跑 `cargo check`。 +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 预检查 | 单次构建 Desktop binary,然后重启应用 | +| `src/crates/services/services-integrations/src/miniapp/loopx_*.rs` | 完成一轮修改,等待用户要求;不跑 Cargo 预检查 | 单次构建 Desktop binary,然后重启应用 | +| `src/crates/assembly/core/src/miniapp/loopx/**` | 完成一轮修改,等待用户要求;不跑 Cargo 预检查 | 单次构建 Desktop binary,然后重启应用 | +| `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`。 +- `src/web-ui/**` 改动由常驻 Vite HMR 直接生效;快速反馈流程不要追加 + `pnpm run type-check:web`,也不要因此重新编译 Rust。只有内嵌 MiniApp source 或 Rust + 发生变化时才需要 Desktop binary build。 + +## 编译影响面约束(写代码前执行) + +1. **先判断 Cargo 影响链再编辑**:Rust 的增量单位主要是 crate,不是单个文件。修改 + `product-domains`、`services-integrations` 或 `bitfun-core` 任一项都会让其下游重新编译; + 同一轮同时触及三者会形成 `contracts → services/core → desktop` 的大范围重编。编辑前 + 必须列出准备触及的 crate,并确认每一层都是当前可见结果所必需。 +2. **UI 问题不扩散到 Rust**:文案、布局、日志展示和交互只改内嵌 MiniApp source; + `src/web-ui/**` 问题只改 Web UI 并走 Vite HMR。不要为了方便把纯展示逻辑放进 Rust, + 也不要因为 Web UI 改动重新编译 Desktop。 +3. **LoopX 私有行为留在最窄 owner**:LoopX 专用投影、去抖、日志摘要和调度逻辑优先 + 留在 `src/crates/assembly/core/src/miniapp/loopx/**`。不要顺手修改全局配置、共享事件、 + runtime、Cargo features 或 manifest;只有真实稳定合同属于下层 owner 时才向下修改, + 不得为编译速度破坏正确架构边界。 +4. **主流程修复与非阻塞改进分轮**:当前问题能在一个 owner 内闭环时,不把 prompt + 润色、共享重构、通用清理或另一层的体验优化塞进同一次真机反馈 build。记录为下一轮, + 等用户看到主流程效果后再决定是否做。 +5. **不制造无关源码变更**:不格式化未触及的 Rust 文件,不调整 Cargo.toml/features, + 不移动模块,不做与当前问题无关的重命名。Cargo 按内容哈希判断脏单元,任何共享源码 + 变化都可能扩大下游重编。 +6. **批量编辑,一次 binary build**:在不编译的状态下完成同一影响链的全部必要修改, + 秒级检查后只构建一次。不要为了逐文件确认而在中间启动 Cargo。 +7. **构建前向用户说明预期影响面**:若不可避免地同时触及多个广泛 crate,编译前简短 + 说明为什么无法保持局部,以及预计会重编哪些层。长期需要进一步提速时,应评审把 + LoopX Desktop wiring / embedded assets 从大 crate 拆到更窄的产品 owner;不要临时用 + 错误依赖方向规避编译。 + +## 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. **极速反馈只运行最终 build**:用户要求“编译看效果”时,不运行 `cargo check`、 + `cargo test`、Web UI type-check 或任何前置构建。它们重复解析/编译依赖,却不产生用户 + 要试用的 exe。若用户另外明确要求某个检查,该检查也必须继承 README 的三个 + `CARGO_PROFILE_DEV_*` 值,禁止运行裸 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 --bin bitfun-desktop + ``` + + 指定 `--bin bitfun-desktop` 用于明确只请求 Desktop binary target,但当前 package 的 + binary 依赖同包 lib,而 `[lib]` 同时声明 `staticlib`、`cdylib`、`rlib`;Cargo 仍会 + 在一次 lib rustc 中生成三种 crate-type,并链接约 30 MB 的 + `bitfun_desktop_lib.dll`。不要宣称 `--bin` 已省掉该阶段。真正移除它需要把移动端/FFI + wrapper 与 Desktop 使用的 rlib 拆成不同 package/target,必须作为独立架构改动评审。 + + 构建前同时检查系统可用提交空间;低于 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 时间戳未变,说明没有产出新应用;不要 + 宣称编译成功,也不要继续运行旧二进制。测试仍只在用户明确要求时运行。 +8. **2026-08-26 极速反馈复盘**:一次主流程修复在最终 build 前运行了两个 + `cargo check`,之后又运行 Web UI type-check;它们分别额外消耗约 1 分 44 秒和 + 1 分 50 秒,且没有让用户更早看到效果。后续把所有修改集中完成后只运行一次 + `cargo build -p bitfun-desktop --bin bitfun-desktop`。运行中新发现的纯 Web UI 问题 + 走 Vite HMR 修复,不再触发第二次 Rust build。 + +## 禁止事项 + +- 不要主动编译:用户没有明确要求时,不执行 `cargo check`、`cargo build`、 + `pnpm run build:loopx` 等任何构建/编译动作。 +- 用户只要求“编译看效果”时,不要自行追加 `cargo check`、`pnpm run type-check:web` + 或测试;最终 binary build 是唯一允许的编译动作。 +- 不要在每次代码改动后主动跑测试套件(见上)。 +- 不要直接修改 `%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..c612fa4cb0 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/README.md @@ -0,0 +1,303 @@ +# 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` | 兼容占位(必须保持无业务逻辑) | `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 预检查 | 联调前单次编译 binary | +| `src/crates/services/services-integrations/src/miniapp/loopx_*.rs` | GitHub、Git workspace、CLI 等宿主服务 | 集中完成修改,不跑 Cargo 预检查 | 联调前单次编译 binary | +| `src/crates/assembly/core/src/miniapp/loopx/**` | controller、Agent 编排、持久状态 | 集中完成修改,不跑 Cargo 预检查 | 联调前单次编译 binary | +| `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 --bin 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`;最终 binary build 会完成同一份 Rust 编译验证并直接产生可试用结果。 +不要同时运行多个 Cargo 命令,也不要在 `desktop:dev` 正在自动编译时再手动启动 +Cargo;它们会争用同一个 target 锁。 + +完成所有宿主改动后再执行一次: + +```bash +cargo build -p bitfun-desktop --bin 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 --bin 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 --bin bitfun-desktop +``` + +- **不要**混用不同的 `CARGO_PROFILE_DEV_*` / `CODEGEN_UNITS` 配置,也不要与 + `--no-default-features` 的裸命令交替使用——那会把本可增量的重编退化成全量冷编译; +- 快速反馈不要在 build 前追加 `cargo check`、测试或 Web UI type-check。指定 + `--bin bitfun-desktop` 明确请求 binary target;但当前同包 lib 同时声明 + `staticlib/cdylib/rlib`,Cargo 仍会生成并链接 `bitfun_desktop_lib.dll`。要去掉该阶段, + 需要把移动端/FFI wrapper 与 Desktop rlib 拆成独立 package/target; +- `src/web-ui/**` 由 Vite HMR 直接刷新,不需要 Rust build;内嵌 MiniApp source + 仍须一次 binary build 才能 reseed; +- 需要断点调试信息时(`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 始终保留。 + +## 权限与信任模型 + +本应用遵守 MiniApp V2 无 Node 规范:`meta.json` 明确设置 `node.enabled=false`, +`worker.js` 只有空兼容导出,UI 不执行 shell、文件或网络原语。普通和市场 MiniApp 的 +`window.app` 公共 API 中没有 LoopX 控制器。 + +编译器只为 id 精确匹配 `builtin-bitfun-loopx` 的非 strict 构建注入私有 +`app.loopx` namespace;Web UI 与 Desktop 在每次调用时继续校验 active scope、原始 +built-in source、非本地覆盖和本地执行域。伪造 id、draft、市场包或修改后的内置源码 +都不能取得该控制器。这个 namespace 是产品私有扩展,不得被其他 MiniApp 使用或模拟。 + +## 平台支持 + +- Desktop 安装包携带固定版本的 LoopX sidecar;开发态缺少资源时只允许使用版本完全 + 匹配的系统 `loopx`,不从 MiniApp 内下载运行时或修改用户 Python 环境。 +- Git workspace、GitHub intake、进程树与 sidecar 探测由 Rust service owner 实现, + 不进入 UI 或 Worker。 +- 当前只支持本地 Desktop workspace。Remote Workspace、Peer Device、Remote Control + 和 Detached Dispatch 均返回明确 unsupported,不静默回退到控制端本机。 + +## 设计假设 + +- `LoopxController` 是进程级 host driver,不依赖 MiniApp iframe 生命周期;关闭或 + 重开界面不会创建第二套心跳。UI 通过 cursor replay 恢复事件。 +- Desktop 待机恢复由 MiniApp 的可见性/焦点/时钟间隙检测触发幂等 attach;可信私有 + attach 会让宿主刷新环境与非运行 Goal 投影,但保留仍可恢复的单一 Agent turn,避免 + 伪造失败或重复启动。活动任务长时间没有事件时 UI 只做有界快照重取。 +- 普通 UI attach 不重复 inspect `WaitingForUser`、终态或显式恢复态 Goal;这些状态由 + 宿主审批/恢复动作直接推进,仅在真实待机恢复的 force reconciliation 中重新向 CLI + 对账。这样等待审批不会每 30 秒生成一个可能超时的 sidecar 进程。 +- Issue 列表与实时日志是一级工作区;点击 Issue 打开全尺寸二级详情层,集中展示阶段、 + 当前动作、有效产出、最新回合结论和原始 Issue 描述。审批门禁同时投影为持久顶部提示, + 新门禁首次出现时自动打开批准/拒绝对话框;提交决定后 UI 立即收起对话框,并以任务 + pending 状态等待宿主确认,不把 CLI 往返延迟表现成按钮卡死。 +- `pending_gate_id/message/action_kind` 属于任务快照的持久投影,审批按钮不依赖可能被 + 截断的历史事件回放。升级前的 `WaitingForUser` 记录若缺少该投影,普通 attach 只做 + 一次 CLI reconciliation 补齐,之后重新进入等待态免轮询路径。 +- 每轮 turn 由专用 `LoopxAgentPort` 通过通用 `AgentSubmissionPort` 启动临时 BitFun + Agent session。通用 Agent loop 不包含 LoopX 分支;LoopX CLI 生成本轮 prompt 和 + writeback contract。 +- LoopX registry 是 goal/todo/gate/quota/settlement 的唯一权威。BitFun 持久化的 + `LoopxTaskState` 只描述 workspace、session、取消和恢复等 host-job 生命周期,并保存 + 最近一次只读 `goalState` 投影;启动环境检查和 UI attach 都会向 CLI 对账。 +- Agent 负责验证真实结果并通过 LoopX CLI 写入 typed durable progress;宿主不从对话文本 + 或进程退出码推断进展。只有读到与 goal、agent、Turn 和 todo/autonomous-replan binding + 全部匹配的 durable writeback 后,宿主才按该 identity 幂等追加并读回 quota receipt。 + 完全缺失 durable writeback 的 turn 进入显式 recovery;宿主不额外启动 + settlement-repair Agent turn,也不伪造 progress。 +- 任务快照在结算前保存有界的最后 Agent 回合总结,供详情页展示分析、方案、产出和 + 下一步;该总结只是 UX 投影,不参与 Goal 状态、durable progress 或 settlement 判定。 + Subscriber 只聚合最后一个模型 round 的最新 attempt,防止中间工具回合或重试文本 + 混入最终总结。 +- **目标模型:一个 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`)。每次 durable settlement + 是公平轮转边界:有其他排队 Issue 时先让出仓库槽,不把当前 task 标记为 pending + 并在同一 worker 内自重入;轮到其他 Issue 结算后再回到当前 Goal。 +- **worktree 成本**:同仓库所有 task 共享一份裸仓库对象库 + (`//bare.git/`,首个 task `git clone --bare` 建立),每个 + task 用 `git worktree add -b bitfun-loopx/` 挂出独立工作区 + (`///`)。磁盘 ≈ 1 份对象库 + 各 task 的检出 + 文件;历史版本升级前创建的旧式独立克隆(每 task 一份完整 `.git`)仍可 + 正常复用,不强制迁移。归档会立即释放单个任务的磁盘:dispose 先 + `git worktree remove --force` 删除该 task 工作区,再按 + `git worktree list` 剩余条目数判断是否删除共享裸仓库(最后一个 worktree + 离开时整仓回收)。loopx 上游只 `connect` 已存在项目、不 clone,克隆策略 + 是宿主侧职责,改动需同步 `loopx_workspace.rs` 与克隆契约测试。 +- **依赖与构建缓存边界**:包管理器下载缓存(例如 Yarn Berry 全局 cache)和 Git 对象库 + 可以跨 task 复用;`node_modules`、构建目录和测试进程属于可变工作树状态,不在并行 + Issue 间直接共享,避免一个 Issue 的安装脚本或平台产物污染另一个 Issue。LoopX 专用 + Agent 会被告知当前目录已是独立 worktree、先检查已有依赖状态、只在工具明确返回 + `session_id` 时使用交互式续写,并对安装、打包和烟测设置有界等待。 +- **全量清空**:重置会先把整个旧 workspace root 原子改名隔离,立即创建新的 + 活动 root,并搬回经过目录边界验证的 `bare.git` 对象缓存;旧 task Worktree + 在后台递归回收。这样不会复用脏工作树或失去 owner 的未结算修改,但下一次同仓库 + 任务不必重新下载完整 Git 历史。要继续已有修改应使用仓库级恢复,不应先重置。 +- **自主回合收敛门禁**:宿主只在 LoopX 已返回 `RunNow`、没有自然 user gate、Goal + 也未终结时计算预算;每 4 个成功 durable settlement 必须通过 typed CLI port 创建 + 一个 agent-scoped `user_gate`。批准后重新获得 4 个有界回合,拒绝则停止 BitFun host + task 并保留 worktree/证据,后续仍可用仓库级恢复继续。预算按结算回执计算,不按工具 + 字符串、命令次数或模型文本猜测;升级后的旧任务也会用 LoopX authoritative receipt + history 补算未审阅回合,因此不会把通用 Agent loop 变成硬编码工作流。 +- 模型成本仍由用户在 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/`)。桌面宿主把 + 资源目录由 Desktop 启动 wiring 传给 `LoopxCliProcessAdapter`,探测时内置二进制 + 优先,用户机器零依赖。开发态资源缺失时只接受版本完全匹配的系统命令。 + 生成的 `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 官方出品。 +- **运行期兜底**:内置二进制缺失时,开发态可回退版本完全匹配的系统 `loopx`; + 版本不一致直接拒绝。升级必须伴随 CLI JSON 契约的显式适配。 +- 本应用自身的 GitHub 凭据只存于本机应用存储(gh CLI 或粘贴的 PAT),不写入 git config。 + +## loopx 依赖升级 + +loopx 不随包分发源码,只 pin 一个经过验证的版本。**不要自动追新**:只有在确实 +需要新版能力/修复时才升级。 + +1. **读 release notes / CHANGELOG**,重点确认 CLI 的 `--format json` 输出契约 + (`turn plan` / `heartbeat-prompt` / `history` / `todo` / `bootstrap`)没有破坏性变更; +2. **改 pin**:同步修改 `scripts/build-loopx.mjs` 与 `loopx_cli.rs` 的版本常量; +3. **本机冒烟**:重建 sidecar 后跑一个 issue 全流程(intake → turn → todo 审批 → + receipt 观察)验证 JSON 契约; +4. **回滚**:恢复上述两个 pin 并重建 sidecar;已持久化 host job 和 LoopX registry + 均保留,不删除用户工作区; +5. **随发布提交**:pin 变更与 `version` bump 一起进发布版(发布版自带的 + sidecar 二进制由打包构建重新编译)。 + +**升级经验(首批开发踩坑记录)**: + +- `heartbeat-prompt` 中的 `${LOOPX_TURN:?}` 必须由 adapter 替换成宿主签发的稳定 + Turn id,不能让 Agent 自行生成; +- sidecar JSON stdout 与进程 stderr 必须分流,stdout 只接受一个结构化文档; +- 不能通过自然语言或进程退出码推断结算成功,只接受 LoopX history/receipt 中与 + goal、agent、todo 和 Turn id 全部匹配的持久证据。 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..49caeb8197 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/index.html @@ -0,0 +1,351 @@ + + + + + + LoopX + + + + +
+
+
+ +
+ LoopX + 正在连接宿主 +
+
+ +
+ + + + + + 模型 + + + +
+ +
+
+ + + 环境 + -- + +
+
+
+
+

核心环境

+ 必需 +
+
+
+
+
+

增强能力

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

模型实时输出

+

所有 Issue 的模型输出会按时间保留在这里

+
+
+
+
+ +

暂无运行事件

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

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

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

    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..32d6ad2f2b --- /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": 14, + "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 live model output 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..3d09d16904 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/style.css @@ -0,0 +1,1032 @@ +*, *::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 { + position: relative; + 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 { position: relative; 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, 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); } +.approval-alert { + flex: 0 0 auto; + display: grid; + grid-template-columns: 24px minmax(220px, 1fr) auto; + align-items: center; + gap: 10px; + margin: 8px 12px 0; + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--lx-warning) 52%, var(--lx-border)); + border-radius: var(--lx-radius); + background: color-mix(in srgb, var(--lx-warning) 5%, var(--lx-elevated)); +} +.approval-alert__signal { + display: grid; + place-items: center; + width: 24px; + height: 24px; + border-radius: 50%; + background: color-mix(in srgb, var(--lx-warning) 18%, transparent); + color: var(--lx-warning); + font-weight: 800; +} +.approval-alert__copy { + min-width: 0; + padding: 1px 4px; + border: 0; + outline: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} +.approval-alert__copy:focus-visible { + border-radius: 4px; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--lx-warning) 42%, transparent); +} +.approval-alert__copy strong { display: block; font-size: 12.5px; line-height: 1.4; overflow-wrap: anywhere; } +.approval-alert__copy p { margin: 3px 0 0; color: var(--lx-text-soft); font-size: 11.5px; line-height: 1.45; overflow-wrap: anywhere; } +.approval-alert__actions { display: flex; align-items: center; gap: 6px; } +.approval-alert__actions button { min-height: 32px; white-space: nowrap; } +.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 { + position: relative; + flex: 0 0 auto; +} +.environment-panel > summary { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 32px; + padding: 4px 9px; + border: 1px solid var(--lx-border); + border-radius: 7px; + background: transparent; + color: var(--lx-text-soft); + cursor: pointer; + list-style: none; +} +.environment-panel > summary::-webkit-details-marker { display: none; } +.environment-panel > summary:hover, .environment-panel[open] > summary { border-color: var(--lx-border-strong); background: var(--lx-hover); } +.environment-panel > summary strong { color: var(--lx-text); font-size: 10px; } +.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 { + position: absolute; + z-index: 30; + top: calc(100% + 8px); + right: 0; + display: grid; + grid-template-columns: 1fr 1fr auto; + align-items: end; + gap: 18px; + width: min(720px, calc(100vw - 24px)); + max-height: min(520px, calc(100dvh - 86px)); + padding: 12px; + overflow: auto; + border: 1px solid var(--lx-border-strong); + border-radius: var(--lx-radius); + background: var(--lx-elevated); + box-shadow: 0 14px 36px color-mix(in srgb, var(--lx-bg) 62%, transparent); +} +.environment-checked { grid-column: 1 / -1; color: var(--lx-text-muted); font-size: 10px; } +.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) 9px minmax(0, 1fr); flex: 1 1 auto; min-height: 0; } +.workbench.tasks-collapsed { grid-template-columns: 52px 9px minmax(0, 1fr); } +.rail-splitter { + position: relative; + cursor: col-resize; + background: transparent; + touch-action: none; +} +.rail-splitter::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: 1px; + height: 100%; + background: var(--lx-border); + transform: translateX(-50%); + transition: background 0.12s ease, width 0.12s ease; +} +.rail-splitter:hover::after, .rail-splitter.is-focused::after, .rail-splitter.is-dragging::after { + width: 2px; + 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-item { + display: grid; + grid-template-columns: 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); +} +@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[data-status="completed"] { color: var(--lx-success); } +.task-item__hint[data-status="queued"], .task-item__hint[data-status="preparing"] { color: var(--lx-info); } +.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-item__main, +.task-rail.is-collapsed .task-empty { display: none; } +.task-rail.is-collapsed .task-item { grid-template-columns: 1fr; min-height: 38px; padding: 6px; } +.task-item__compact { display: none; color: var(--lx-text-soft); font: 600 10px/1 var(--lx-mono); text-align: center; } +.task-rail.is-collapsed .task-item__compact { display: block; } +.task-rail.is-collapsed .task-item__state { + width: 9px; + height: 9px; + margin: auto; + overflow: hidden; + border-radius: 50%; + background: var(--lx-text-muted); + color: transparent; + font-size: 0; +} +.task-rail.is-collapsed .task-item__state[data-status="running"], .task-rail.is-collapsed .task-item__state[data-status="queued"], .task-rail.is-collapsed .task-item__state[data-status="preparing"] { background: var(--lx-info); } +.task-rail.is-collapsed .task-item__state[data-status="waiting_for_user"], .task-rail.is-collapsed .task-item__state[data-status="retry_wait"] { background: var(--lx-warning); } +.task-rail.is-collapsed .task-item__state[data-status="failed"], .task-rail.is-collapsed .task-item__state[data-status="recovery_required"] { background: var(--lx-error); } +.task-rail.is-collapsed .task-item__state[data-status="completed"] { background: var(--lx-success); } +.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-header--activity { align-items: center; min-height: 48px; padding-block: 6px; } +.log-header--activity .log-title-block { min-width: 0; } +.log-header--activity h1 { margin: 0; font-size: 13px; } +.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: 12px; text-overflow: ellipsis; white-space: nowrap; } +.issue-link { + display: block; + max-width: 420px; + margin: 2px 0 0; + overflow: hidden; + color: var(--lx-text-muted); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; + text-decoration: none; +} +.issue-link:hover { color: var(--lx-accent, inherit); text-decoration: underline; } +.issue-link::after { content: "↗"; margin-left: 4px; text-decoration: none; } +.issue-approval-panel { + padding: 14px; + border-bottom: 1px solid color-mix(in srgb, var(--lx-warning) 38%, var(--lx-border)); + background: color-mix(in srgb, var(--lx-warning) 6%, var(--lx-surface)); +} +.issue-approval-header { + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + align-items: start; + gap: 10px; +} +.issue-approval-signal { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: 50%; + background: color-mix(in srgb, var(--lx-warning) 18%, transparent); + color: var(--lx-warning); + font-weight: 800; +} +.issue-approval-header h2 { margin: 2px 0 0; font-size: 15px; line-height: 1.35; } +.issue-approval-header p { margin: 5px 0 0; color: var(--lx-text-soft); font-size: 12px; line-height: 1.55; overflow-wrap: anywhere; } +.issue-approval-effects { + display: grid; + grid-template-columns: 1fr 1fr; + margin: 12px 0 0 38px; + overflow: hidden; + border: 1px solid var(--lx-border); + border-radius: var(--lx-radius); + background: var(--lx-surface); +} +.issue-approval-effects section { min-width: 0; padding: 9px 10px; } +.issue-approval-effects section + section { border-left: 1px solid var(--lx-border); } +.issue-approval-effects strong { color: var(--lx-text); font-size: 12px; } +.issue-approval-effects section[data-tone="approve"] strong { color: var(--lx-success); } +.issue-approval-effects section[data-tone="reject"] strong { color: var(--lx-error); } +.issue-approval-effects p { margin: 3px 0 0; color: var(--lx-text-soft); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; } +.issue-approval-recommendation { + margin: 9px 0 0 38px; + color: var(--lx-text); + font-size: 12px; + line-height: 1.5; +} +.issue-approval-note { margin: 10px 0 0 38px; } +.issue-approval-actions { display: flex; justify-content: flex-end; gap: 7px; margin: 10px 0 0 38px; } +.issue-approval-actions button { min-width: 92px; } +.issue-progress-panel { + flex: 0 0 auto; + border-bottom: 1px solid var(--lx-border); + background: var(--lx-surface); +} +.issue-progress-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 32px; + padding: 6px 14px; +} +.issue-progress-heading h2 { margin: 0; font-size: 12px; } +.issue-progress-heading span { color: var(--lx-text-muted); font-size: 12px; } +.issue-stage-track { + position: relative; + overflow: hidden; + border-block: 1px solid var(--lx-border); +} +.issue-stage-track::before { + content: ''; + position: absolute; + top: 26px; + right: 10%; + left: 10%; + height: 1px; + background: var(--lx-border-strong); +} +.issue-stage-list { + display: grid; + grid-template-columns: repeat(5, minmax(116px, 1fr)); + margin: 0; + padding: 0; + overflow-x: auto; + list-style: none; +} +.issue-stage-list li { + position: relative; + display: grid; + grid-template-columns: 8px minmax(0, 1fr); + gap: 2px 7px; + min-width: 0; + min-height: 48px; + padding: 7px 10px; + border-right: 1px solid var(--lx-border); +} +.issue-stage-list li:last-child { border-right: 0; } +.issue-stage-list li::before { + content: ''; + grid-row: 1 / 3; + align-self: center; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--lx-text-muted); +} +.issue-stage-list li[data-status="active"]::before { background: var(--lx-info); animation: pulse-stage 1.4s ease-in-out infinite; } +.issue-stage-list li[data-status="complete"]::before { background: var(--lx-success); } +.issue-stage-list li[data-status="blocked"]::before { background: var(--lx-error); } +.issue-stage-list strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } +.issue-stage-list span { color: var(--lx-text-muted); font-size: 12px; } +.issue-stage-list li[data-status="active"] span { color: var(--lx-info); } +.issue-stage-list li[data-status="complete"] span { color: var(--lx-success); } +.issue-stage-list li[data-status="blocked"] span { color: var(--lx-error); } +@keyframes pulse-stage { 50% { opacity: .45; } } +.issue-progress-grid { + display: grid; + grid-template-columns: minmax(180px, .9fr) minmax(260px, 1.35fr) minmax(180px, .9fr); +} +.issue-progress-grid > section { + min-width: 0; + padding: 9px 14px 10px; + border-right: 1px solid var(--lx-border); +} +.issue-progress-grid > section:last-child { border-right: 0; } +.issue-progress-grid h3 { + margin: 0 0 5px; + color: var(--lx-text-muted); + font-size: 12px; + font-weight: 600; +} +.issue-progress-grid strong { display: block; color: var(--lx-text); font-size: 12.5px; line-height: 1.4; } +.issue-progress-grid p { margin: 3px 0 0; color: var(--lx-text-soft); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; } +.issue-progress-grid ul { display: grid; gap: 2px; margin: 0; padding: 0; color: var(--lx-text-soft); font-size: 12px; list-style: none; } +.issue-progress-grid li { overflow-wrap: anywhere; } +.issue-progress-grid li::before { content: '·'; margin-right: 6px; color: var(--lx-text-muted); } +.issue-outcome-panel { border-top: 1px solid var(--lx-border); } +.issue-outcome-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 32px; + padding: 6px 14px; + color: var(--lx-text-soft); + font-size: 12px; + font-weight: 600; +} +.issue-outcome-heading h3 { margin: 0; font-size: 12px; } +.issue-outcome-heading span { color: var(--lx-text-muted); font-weight: 400; } +.issue-outcome { margin: 0; padding: 0 14px 12px; color: var(--lx-text-soft); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; } +.issue-description-panel { + flex: 0 0 auto; + border-bottom: 1px solid var(--lx-border); + background: var(--lx-surface); +} +.issue-description-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 30px; + padding: 6px 14px; + color: var(--lx-text-soft); + font-size: 12px; + font-weight: 600; +} +.issue-description-heading h2 { margin: 0; font-size: 12px; } +.issue-number { color: var(--lx-text-muted); font: 12px/1.2 var(--lx-mono); } +.issue-description { + padding: 3px 14px 12px; + color: var(--lx-text-soft); + font-size: 12px; + line-height: 1.65; + overflow-wrap: anywhere; +} +.markdown-body > :first-child { margin-top: 0; } +.markdown-body > :last-child { margin-bottom: 0; } +.markdown-body p, .markdown-body ul, .markdown-body ol, .markdown-body blockquote, .markdown-body pre { margin: 8px 0; } +.markdown-body h3, .markdown-body h4, .markdown-body h5, .markdown-body h6 { margin: 12px 0 6px; color: var(--lx-text); font-size: 12px; } +.markdown-body ul, .markdown-body ol { padding-left: 22px; } +.markdown-body blockquote { padding-left: 10px; border-left: 2px solid var(--lx-border-strong); color: var(--lx-text-muted); } +.markdown-body code { padding: 1px 4px; border-radius: 4px; background: var(--lx-element); font-family: var(--lx-mono); } +.markdown-body pre { padding: 8px 10px; overflow: auto; background: var(--lx-muted-surface); } +.markdown-body pre code { padding: 0; background: transparent; } +.markdown-body a { color: var(--lx-accent); } +.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"], .state-pill[data-state="recovery_required"] { color: var(--lx-error); } +.task-actions { display: flex; align-items: center; gap: 7px; min-width: 0; } +.task-actions { justify-content: flex-end; } +.task-actions button { min-height: 30px; padding: 4px 10px; font-size: 10.5px; white-space: nowrap; } +.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: 0; + padding: 8px 14px; + border: 0; + border-bottom: 1px solid color-mix(in srgb, var(--lx-border) 72%, transparent); + border-left: 3px solid var(--lx-info); + background: transparent; +} +.turn-output-row:hover { background: var(--lx-hover); } +.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__issue { + color: var(--lx-text); + font: 600 10px/1.2 var(--lx-font); + text-decoration: none; +} +.output-block__issue:hover { color: var(--lx-accent); text-decoration: underline; } +.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-tool-summary { + margin-top: 4px; + padding-left: 8px; + border-left: 2px solid var(--lx-border-strong); + color: var(--lx-text-soft); + font: 10px/1.5 var(--lx-mono); + overflow-wrap: anywhere; + white-space: pre-wrap; +} +.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; + z-index: 5; + bottom: 18px; + left: 50%; + display: grid; + place-items: center; + width: 34px; + height: 34px; + padding: 0; + border: 1px solid var(--lx-accent); + border-radius: 50%; + background: var(--lx-elevated); + color: var(--lx-accent); + box-shadow: 0 7px 20px color-mix(in srgb, var(--lx-bg) 55%, transparent); + transform: translateX(-50%); +} +.new-events:hover { background: var(--lx-accent); color: white; } +@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; } + .task-actions { flex-wrap: wrap; } + .task-actions { justify-content: flex-start; } + .issue-approval-effects { grid-template-columns: 1fr; } + .issue-approval-effects section + section { border-top: 1px solid var(--lx-border); border-left: 0; } + .issue-progress-grid { grid-template-columns: 1fr; } + .issue-progress-grid > section { border-right: 0; border-bottom: 1px solid var(--lx-border); } + .issue-progress-grid > section:last-child { border-bottom: 0; } +} + +.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); } + +.issue-detail-dialog { + width: min(1180px, calc(100vw - 28px)); + height: min(780px, calc(100dvh - 28px)); + max-width: none; + max-height: none; + padding: 0; + overflow: hidden; + border: 1px solid var(--lx-border-strong); + border-radius: var(--lx-radius); + background: var(--lx-surface); + color: var(--lx-text); + box-shadow: 0 20px 70px color-mix(in srgb, var(--lx-bg) 72%, transparent); +} +.issue-detail-dialog::backdrop { background: color-mix(in srgb, var(--lx-bg) 78%, transparent); } +.issue-detail-sheet { display: flex; flex-direction: column; width: 100%; height: 100%; min-height: 0; } +.issue-detail-header { flex: 0 0 auto; align-items: center; padding: 9px 12px; background: var(--lx-elevated); } +.issue-detail-close { align-self: flex-start; margin-top: 1px; } +.issue-detail-scroll { min-height: 0; overflow: auto; } +.issue-detail-dialog .issue-progress-panel, +.issue-detail-dialog .issue-description-panel { background: transparent; } +.issue-detail-dialog .issue-outcome { max-height: none; } +.issue-detail-dialog .issue-description { max-height: none; } + +@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; } +} + +@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); } + .rail-splitter { display: none; } + .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; } + .preview-facts { grid-template-columns: 1fr; } + .approval-alert { grid-template-columns: 22px minmax(0, 1fr); } + .approval-alert__actions { grid-column: 1 / -1; justify-content: flex-end; } + .approval-alert__actions button { flex: 1 1 0; } + .issue-detail-dialog { width: 100vw; height: 100dvh; border: 0; border-radius: 0; } + .issue-detail-header { align-items: flex-start; } + .issue-detail-dialog .issue-stage-list { grid-template-columns: repeat(5, minmax(104px, 1fr)); } + .issue-approval-effects, + .issue-approval-recommendation, + .issue-approval-note, + .issue-approval-actions { margin-left: 0; } +} + +@media (max-width: 430px) { + .model-field { max-width: 150px; } + .model-field__select { width: 88px; } + .task-actions { justify-content: flex-start; flex-wrap: wrap; } + .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..a7dc9017bd --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/test/thin-client-runtime.test.mjs @@ -0,0 +1,656 @@ +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, + lastAgentSummary: null, + lastAgentSummaryAt: null, + 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: 'log', + level: 'info', + source: 'agent', + phase: 'agent_running', + message: 'Tool started: ExecCommand', + important: false, + toolName: 'ExecCommand', + deadlineAt: now + 120000, + details: { + activity: 'started', + toolName: 'ExecCommand', + summary: 'cargo test -p bitfun-core', + }, + 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 storedHistory = []; + 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(request) { + return { + status: 'current', + taskId: task.taskId, + turnId: task.currentTurnId, + streamId: 'output-stream-1', + events: request.afterCursor > 0 ? [] : [{ + cursor: 1, + turnId: task.currentTurnId, + roundId: 'round-1', + kind: 'thinking', + text: 'Inspecting the issue and repository state', + toolName: null, + toolState: null, + isEnd: true, + }, { + cursor: 2, + turnId: task.currentTurnId, + roundId: 'round-1', + kind: 'tool', + text: 'cargo test -p bitfun-core', + toolName: 'ExecCommand', + toolState: 'started', + isEnd: false, + }], + nextCursor: 2, + 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, + storage: { + async get() { return []; }, + async set(key, value) { storedHistory.push([key, structuredClone(value)]); }, + }, + 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, /Issue #2382/); + assert.match(window.document.querySelector('#log-list').textContent, /Inspecting the issue/); + assert.match(window.document.querySelector('#log-list').textContent, /cargo test -p bitfun-core/); + + 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 new Promise((resolve) => setTimeout(resolve, 0)); + assert.doesNotMatch(window.document.querySelector('#log-list').textContent, /Validation produced one warning/); + + 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 originalDateNow = window.Date.now; + const resumedAt = originalDateNow() + 60000; + window.Date.now = () => resumedAt; + window.dispatchEvent(new window.Event('focus')); + await waitFor(() => attachRequests.length === 3, 'host resume reattach'); + assert.equal(attachRequests[2].resumeDetected, true); + window.Date.now = originalDateNow; + + 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, + ); + assert.deepEqual(plain(storedHistory), [[ + 'loopx.intakeHistory', + ['https://github.com/GCWing/BitFun/issues/2382'], + ]]); + assert.equal( + window.document.querySelector('#intake-history option').value, + 'https://github.com/GCWing/BitFun/issues/2382', + ); + + 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.equal( + window.document.querySelector('[data-task-id="task-2382-1"]').getAttribute('aria-pressed'), + 'true', + ); + assert.equal(window.document.querySelector('#issue-detail-dialog').open, true); + assert.equal(window.document.querySelector('#issue-progress-panel').hidden, false); + assert.deepEqual(forbiddenAccesses, []); + assert.deepEqual(jsdomErrors, []); + } finally { + window.close(); + } +}); + +test('task rail is flat and exposes one repository recovery action', 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', { + pendingGateId: 'todo_release_approval', + pendingGateMessage: 'Approve creating the draft pull request', + pendingGateActionKind: 'gate', + lastAgentSummary: '## Root cause\nThe focus behavior is caused by the current restore path.\n\n## Output\nA bounded implementation plan is ready.', + lastAgentSummaryAt: now - 2000, + }); + const failed = makeTask('task-failed', 43, 'recovery_required', 'recovering', { + error: 'LoopX process exited with status 1', + }); + const resolvedUpstream = makeTask('task-resolved-upstream', 46, 'recovery_required', 'recovering', { + error: 'Settlement metadata was incomplete', + lastAgentSummary: 'The original failure path is covered-upstream no-follow-up; no PR is required.', + lastAgentSummaryAt: now - 1500, + }); + 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, resolvedUpstream]; + 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-item').length === 5, + 'flat task list', + ); + await waitFor( + () => window.document.querySelector('#issue-detail-dialog').open, + 'automatic approval focus', + ); + + assert.equal(window.document.querySelectorAll('#task-items .task-group').length, 0); + assert.deepEqual( + [...window.document.querySelectorAll('#task-items .task-item')].map((item) => item.dataset.taskId), + ['task-running', 'task-waiting', 'task-queued', 'task-failed', 'task-resolved-upstream'], + ); + assert.equal(window.document.querySelector('#repository-actions').hidden, false); + assert.match(window.document.querySelector('#resume-repository').textContent, /repository tasks \(1\)/i); + assert.match( + window.document.querySelector('[data-task-id="task-waiting"]').textContent, + /Waiting for approval/, + ); + assert.equal(window.document.querySelector('#approval-alert').hidden, false); + assert.match( + window.document.querySelector('[data-task-id="task-failed"]').textContent, + /Interrupted/, + ); + 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, /5 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(); + assert.equal(window.document.querySelector('#issue-progress-panel').hidden, false); + assert.equal(window.document.querySelectorAll('#issue-stage-list > li').length, 5); + assert.match(window.document.querySelector('#issue-outcome').textContent, /waiting for your decision/i); + assert.equal(window.document.querySelector('#issue-description-panel').hidden, false); + await waitFor( + () => /temporarily unavailable/.test(window.document.querySelector('#issue-description').textContent), + 'selected task metadata refresh', + ); + assert.equal(window.document.querySelector('#task-actions button'), null); + assert.equal(window.document.querySelector('#issue-approval-panel').hidden, false); + assert.match( + window.document.querySelector('#issue-approval-title').textContent, + /publish the fix and create a pull request/i, + ); + assert.match(window.document.querySelector('#issue-approval-approve-effect').textContent, /does not merge/i); + assert.doesNotMatch(window.document.querySelector('#issue-detail-dialog').textContent, /Root cause|todo_|settlement_result|durable_writeback/i); + + const resolvedButton = window.document.querySelector('[data-task-id="task-resolved-upstream"]'); + assert.match(resolvedButton.textContent, /Resolved upstream/); + assert.equal(resolvedButton.dataset.state, 'completed'); + resolvedButton.click(); + assert.equal(window.document.querySelector('#selected-state').textContent, 'Resolved upstream'); + assert.equal( + [...window.document.querySelectorAll('#issue-stage-list > li')] + .every((stage) => stage.dataset.status === 'complete'), + true, + ); + assert.match(window.document.querySelector('#issue-outcome').textContent, /does not need another repair patch/i); + assert.equal(window.document.querySelector('#task-actions button'), null); + 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..9ea6c54f45 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/test/thin-client-source-contract.test.mjs @@ -0,0 +1,213 @@ +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', + ); + + assert.match(html, /\blist\s*=\s*(['"])intake-history\1/i); + assert.match(html, /\bid\s*=\s*(['"])resume-repository\1/i); + assert.match(html, /\bid\s*=\s*(['"])issue-progress-panel\1/i); + assert.match(html, /\bid\s*=\s*(['"])issue-stage-list\1/i); + assert.match(html, /\bid\s*=\s*(['"])issue-detail-dialog\1/i); + assert.match(html, /\bid\s*=\s*(['"])approval-alert\1/i); + assert.match(html, /\bid\s*=\s*(['"])issue-approval-panel\1/i); + assert.match(html, /\bid\s*=\s*(['"])issue-approval-approve\1/i); + assert.match(html, /\bid\s*=\s*(['"])issue-approval-reject\1/i); + assert.match(html, /\bid\s*=\s*(['"])issue-description-panel\1/i); + assert.match(html, /\bid\s*=\s*(['"])issue-outcome\1/i); + assert.ok( + html.indexOf('id="issue-description-panel"') < html.indexOf('id="issue-progress-panel"'), + 'the visible Issue description must appear before detailed progress', + ); + for (const removedId of [ + 'sync-button', + 'mode-key', + 'mode-full', + 'mode-output', + 'log-search', + 'errors-only', + 'export-logs', + 'liveness-panel', + 'approval-alert-review', + 'issue-stage-walker', + 'gate-dialog', + ]) { + assert.doesNotMatch(html, new RegExp(`\\bid\\s*=\\s*(['"])${removedId}\\1`, 'i')); + } +}); + +test('LoopX keeps intake history and renders one flat repository task list', async () => { + const ui = await readAsset('ui.js'); + + assert.match(ui, /app\.storage\.get\s*\(INTAKE_HISTORY_STORAGE_KEY\)/); + assert.match(ui, /app\.storage\.set\s*\(INTAKE_HISTORY_STORAGE_KEY/); + assert.match(ui, /sortedTaskList\(tasks\)\.forEach\(\(task\)\s*=>\s*fragment\.append\(taskButton\(task\)\)\)/); + assert.doesNotMatch(ui, /group\.className\s*=\s*['"]task-group['"]/); + assert.doesNotMatch(ui, /makeActionButton\([^)]*['"]resume['"]/s); + assert.match(ui, /task\.lastAgentSummary/); + assert.match(ui, /function isResolvedUpstream\(task\)/); + assert.match(ui, /autonomyApprovalSummaryAnalyzed/); + assert.doesNotMatch(ui, /Several investigation stages have completed/); + assert.match(ui, /resumeDetected:\s*true/); + assert.match(ui, /outputBlockDomKey/); + assert.match(ui, /syncApprovalAttention/); + assert.match(ui, /task\.pendingGateId/); + assert.match(ui, /visibilitychange/); + assert.match(ui, /pageshow/); + assert.match(ui, /STALE_ACTIVE_REATTACH_MS/); + assert.match(ui, /focusedTaskId/); + assert.match(ui, /resetLoopxDialog\.close\(\)[\s\S]*resettingLoopxBackground/); +}); 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..c64d46791d --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/ui.js @@ -0,0 +1,3737 @@ +'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_OUTPUT_BLOCKS = 500; +const MAX_TURN_OUTPUT_EVENTS = 800; +const MAX_OUTPUT_HISTORY_EVENTS = 2400; +const MAX_OUTPUT_EVENT_CHARS = 16000; +const MAX_OUTPUT_BLOCK_CHARS = 120000; +const MAX_OUTPUT_HISTORY_CHARS = 1200000; +const MAX_INTAKE_HISTORY = 12; +const HOST_CLOCK_TICK_MS = 5000; +const HOST_RESUME_GAP_MS = 30000; +const STALE_ACTIVE_REATTACH_MS = 30000; +const MODEL_SELECTION_STORAGE_KEY = 'loopx.modelId'; +const INTAKE_HISTORY_STORAGE_KEY = 'loopx.intakeHistory'; +const HIGH_RISK_SCOPES = new Set([ + 'publish', + 'public_comment', + 'pull_request', + 'merge', + 'production_action', +]); +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: '正在加载模型…', + modelEmpty: '未找到已启用的文本模型', + modelLoadFailed: '模型列表加载失败', + modelReloadTitle: '刷新模型列表', + modelSelectionChanged: '已切换模型,请重新分析链接。', + modelPrimaryTag: '主模型', + resolve: '分析链接', + resolving: '正在实时复核链接', + refresh: '刷新状态', + resetLoopx: '重置 LoopX', + resettingLoopx: '正在重置 LoopX…', + resettingLoopxBackground: '正在后台清理任务与工作进展;窗口可以继续使用,完成后会自动刷新。', + destructiveAction: '危险操作', + resetLoopxTitle: '清空并重新开始', + resetLoopxMessage: '将停止并删除 {tasks} 个任务、{events} 条日志、全部工作进展和受管工作区。此操作不可撤销。', + resetLoopxRetained: '模型配置、GitHub 登录、MiniApp 设置和干净的 Git 对象缓存会保留;仍在进行的工作区不会被复用。', + resetLoopxConfirm: '清空并重新开始', + resetLoopxApplied: 'LoopX 已清空,可以重新开始测试。', + unsupportedTitle: '当前执行位置不支持 LoopX', + unsupportedDefault: 'LoopX 目前只支持本地 Desktop 工作区;远程工作区不会静默改在本机执行。', + environment: '环境', + coreEnvironment: '核心环境', + optionalEnvironment: '增强能力', + required: '必需', + optional: '可选', + retryEnvironment: '重新检查环境', + tasks: '任务', + collapseTasks: '收起任务栏', + resizeTasks: '调整任务栏宽度', + expandTasks: '展开任务栏', + allActivity: '全部活动', + noTasks: '暂无任务', + activity: '模型实时输出', + allTaskEvents: '所有 Issue 的模型输出会按时间保留在这里', + 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} 段', + sourceScheduler: '任务调度', + sourceLoopx: 'LoopX 引擎', + sourceAgent: 'Agent', + sourceGit: 'Git', + sourceGithub: 'GitHub', + sourceSystem: '系统', + toolExecCommand: '执行命令', + toolRead: '读取文件', + toolGrep: '搜索内容', + toolLs: '浏览目录', + toolWebFetch: '访问网页', + toolWebSearch: '搜索网页', + toolWrite: '写入文件', + toolEdit: '修改文件', + toolQueued: '工具已排队:{tool}', + toolWaiting: '工具等待中:{tool}', + toolStarted: '正在运行:{tool}', + toolConfirmation: '工具等待确认:{tool}', + toolConfirmed: '已确认工具:{tool}', + toolRejected: '已拒绝工具:{tool}', + toolCompleted: '工具完成:{tool}', + toolFailed: '工具失败:{tool}', + toolCancelled: '工具已取消:{tool}', + toolStateQueued: '排队', + toolStateWaiting: '等待', + toolStateStarted: '运行中', + toolStateConfirmation: '等待确认', + toolStateConfirmed: '已确认', + toolStateRejected: '已拒绝', + toolStateCompleted: '已完成', + toolStateFailed: '失败', + toolStateCancelled: '已取消', + detailDurationMs: '耗时(毫秒)', + detailExitCode: '退出码', + detailMatchCount: '匹配数', + detailFileCount: '文件数', + detailEntryCount: '条目数', + detailLineCount: '读取行数', + detailContentLength: '内容长度', + detailTitle: '标题', + detailQueuePosition: '队列位置', + detailDependencies: '等待项', + 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: '确认新尝试', + decisionRequired: '需要你的决定', + afterApprove: '批准后', + afterReject: '拒绝后', + 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: '阶段', + goal: '目标', + deadline: '截止', + noRecentOutput: '已 {duration} 没有新输出', + lastOutput: '最后输出 {duration} 前', + updated: '更新于 {duration} 前', + openInGithub: '在 GitHub 中打开', + issueProgress: '处理进度', + currentWork: '当前', + evidenceAndOutputs: '已完成的工作', + nextAction: '下一步', + latestOutcome: '最近进展', + outcomeUpdated: '{duration}前更新', + stageWorkspace: '工作区', + stageAnalysis: '分析与方案', + stageImplementation: '实施', + stageValidation: '结果核验', + stageSettlement: '完成', + stagePending: '待开始', + stageActive: '进行中', + stageComplete: '已完成', + stageBlocked: '已阻塞', + progressMeta: '{done}/{total} 个阶段已完成', + progressPreparing: '正在准备独立 Worktree', + progressQueued: '等待同仓库前序 Issue', + progressAnalyzing: '正在分析原因并形成可执行方案', + progressImplementing: '已进入代码修改阶段', + progressValidating: '正在核验本轮产出', + progressSettling: '正在保存本轮进展', + progressWaiting: '等待你的决定', + progressRecovery: '本轮执行已中断', + progressCompleted: '修复流程已完成', + progressResolvedUpstream: '上游已处理该问题', + progressResolvedUpstreamDetail: '已确认当前上游代码移除了原始故障路径,不需要再提交额外修复。', + progressIdle: '等待任务推进', + evidenceWorkspace: '独立工作区已准备完成', + evidenceProgressSaved: '当前工作进展已保存,中断后可以继续', + evidenceAnalysisReady: '已完成问题分析并形成修复方向', + evidenceCodeChanges: '已完成代码修改(涉及 {value} 个文件)', + evidenceCodeChangesUnknown: '已完成代码修改', + evidenceValidationPassed: '已运行项目验证,当前未发现失败', + evidenceValidationNeedsAttention: '部分验证未通过,仍需继续处理', + evidenceResolvedUpstream: '已核对当前上游代码,原始故障路径已被移除', + evidenceNoPatchRequired: '当前没有需要提交的修复补丁', + evidenceNone: '任务尚未产生可展示的工作结果', + nextPrepare: '完成 Worktree 后开始分析 Issue。', + nextQueued: '等待仓库执行槽释放后自动开始。', + nextAnalyze: '完成原因定位并形成可执行方案。', + nextImplement: '继续实现并保留可核验的修改证据。', + nextValidate: '核验当前产出并保存进展。', + nextSettle: '保存进展后继续下一阶段或完成任务。', + nextRecover: '处理执行异常后继续任务。', + nextApproval: '查看审批内容并决定是否继续。', + nextCompleted: '检查最终结论和产出。', + nextResolvedUpstream: '无需继续修复,可以保留记录或归档任务。', + issueDescription: 'Issue 描述', + loadingIssueDescription: '正在加载 Issue 描述…', + issueDescriptionUnavailable: '暂时无法加载 Issue 描述。', + publishApprovalTitle: '是否发布修复并创建 Pull Request?', + publishApprovalSummary: '修复已在分支 {branch} 的提交 {commit} 中准备完成,目标仓库为 {repository}。现在需要你决定是否发布。', + publishApprovalSummaryGeneric: '修复和发布材料已经准备完成,目标仓库为 {repository}。现在需要你决定是否发布为 Pull Request。', + publishApprovalApproveEffect: '推送修复分支并创建 Pull Request,随后进入 macOS 真机验证。批准不会自动合并代码。', + publishApprovalRejectEffect: '不推送分支,也不创建 Pull Request;本地分支、提交和验证结果会保留,任务停在当前步骤。', + publishApprovalRecommendationReady: '建议批准:当前修改已有验证结果,批准后仍可在 Pull Request 中继续评审,并不会自动合并。', + publishApprovalRecommendationReview: '建议先确认修改和验证结果;批准只会发布 Pull Request,不会自动合并。', + publishApprovalApprove: '批准并创建 PR', + publishApprovalReject: '暂不发布', + autonomyApprovalTitle: '修复尚未完成,是否继续处理?', + autonomyApprovalSummaryAnalyzed: '已经完成问题分析并找到继续处理的方向,但修复和验证尚未完成。', + autonomyApprovalSummaryImplemented: '已经产生修复修改,但还需要继续验证并确认最终结果。', + autonomyApprovalApproveEffect: '继续修复和验证,完成后汇报结果。不会发布或合并代码。', + autonomyApprovalRejectEffect: '暂停处理并保留当前工作区、修改和调查结果。', + autonomyApprovalRecommendation: '建议继续:当前已经形成明确方向,下一步主要是实施和验证。', + continueRepair: '继续处理', + pauseProcessing: '暂不继续', + genericApprovalTitle: '是否允许任务执行下一步?', + genericApprovalSummary: '任务已到需要人工确认的操作。请根据下面的影响决定是否继续。', + genericApprovalApproveEffect: '允许任务执行本次操作,并继续后续流程。', + genericApprovalRejectEffect: '不执行本次操作,保留已有修改和进展,任务停在当前步骤。', + genericApprovalRecommendation: '建议:确认操作范围符合预期后再批准;不确定时先拒绝,并在备注中说明需要补充的信息。', + outcomeWaiting: '代码修改和已有验证结果已保存,任务正在等待你决定是否执行下一步。', + outcomeRecovery: '任务执行过程中断。已有调查结果和修改仍然保留,恢复后可以从当前进度继续。', + outcomeCompleted: '修复流程已经完成,可以检查最终产出和验证结果。', + outcomeResolvedUpstream: '已确认当前上游代码已经移除导致问题的旧路径,本任务无需再提交修复。', + outcomeValidated: '修复修改已经完成,并已进入结果核验或等待后续操作。', + outcomeImplemented: '已经产生代码修改,当前正在补充验证并收敛最终结果。', + outcomeAnalyzed: '已经完成问题分析,正在形成或实施修复方案。', + outcomeQueued: '任务已经创建,正在等待工作区或仓库执行槽就绪。', + outcomeStarted: '任务已经开始,当前进展会持续更新。', + 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: '操作已应用。', + approvalSubmitting: '正在提交审批决定,任务会在宿主确认后继续。', + approvalSubmittingShort: '正在提交决定…', + actionPending: '正在提交操作', + pausePending: '正在暂停', + resumePending: '正在继续', + archivePending: '正在归档', + actionDuplicate: '该操作已经应用,无需重复执行。', + revisionConflict: '任务状态已经变化,已刷新到最新版本。', + actionRejected: '宿主拒绝了该操作。', + environmentRetryQueued: '环境检查已重新启动。', + logsExported: '日志已导出。', + noGate: '没有找到可回答的审批门禁,请刷新任务状态。', + approvalNeeded: '任务正在等待远程可回答的审批。', + continueInvestigation: '继续调查', + stopTask: '停止任务', + activityInstallingDependencies: '正在准备项目依赖', + activityBuildingInstaller: '正在构建 Windows 安装包', + activityTestingUpgrade: '正在验证安装器升级链路', + activityWaitingProcess: '正在等待外部进程返回结果', + activitySyncingProgress: '正在同步工作进展', + activityCheckingRepository: '正在检查仓库状态', + activityRunningCommand: '正在执行项目命令', + coreBlocked: '核心环境未就绪,任务不会静默启动。', + optionalDegraded: '增强能力不可用,核心修复流程仍可继续。', + truncatedCandidates: '候选项已截断,请缩小仓库范围后重新分析。', + imageWarning: '所选内容包含图片,但当前模型不支持图片输入。', + modelUnavailable: '当前模型不可用,请返回并选择其他模型。', + workspaceUnavailable: '宿主无法为该仓库准备受信任的 Worktree。', + resolvedItem: '已处理', + openItem: '开放', + fromRepository: '仓库候选', + attempt: '尝试 {value}', + taskNumber: '任务 {value}', + eventCount: '{value} 条事件', + allSources: '全部来源', + sidecar: 'LoopX 引擎', + 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: '已归档', + state_resolved_upstream: '上游已修复', + phase_unknown: '等待宿主状态', + phase_validating_environment: '验证环境', + phase_resolving_intake: '复核输入', + phase_preparing_workspace: '准备独立工作区', + phase_creating_goal: '准备任务目标', + phase_queued: '等待调度', + phase_inspecting_goal: '检查任务目标', + phase_building_turn: '正在准备下一阶段', + phase_starting_agent: '启动修复任务', + phase_agent_running: '正在分析或修改', + phase_validating_progress: '正在核验结果', + phase_settling_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...', + modelEmpty: 'No enabled text models found', + modelLoadFailed: 'Model list failed to load', + 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...', + resettingLoopxBackground: 'Cleaning tasks and saved progress in the background. You can keep using this window; it refreshes when cleanup finishes.', + destructiveAction: 'Destructive action', + resetLoopxTitle: 'Clear and start over', + resetLoopxMessage: 'Stop and delete {tasks} tasks, {events} log events, all saved progress, and all managed workspaces. This cannot be undone.', + resetLoopxRetained: 'Model configuration, GitHub login, MiniApp settings, and clean Git object caches are retained. Unsettled worktrees are not reused.', + 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. Remote workspaces will not silently run on this device instead.', + 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: 'Live model output', + allTaskEvents: 'Model output for every issue is retained here in time order', + 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 model 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', + sourceScheduler: 'Task scheduler', + sourceLoopx: 'LoopX engine', + sourceAgent: 'Agent', + sourceGit: 'Git', + sourceGithub: 'GitHub', + sourceSystem: 'System', + toolExecCommand: 'Run command', + toolRead: 'Read file', + toolGrep: 'Search content', + toolLs: 'Browse directory', + toolWebFetch: 'Fetch web page', + toolWebSearch: 'Search web', + toolWrite: 'Write file', + toolEdit: 'Edit file', + toolQueued: 'Tool queued: {tool}', + toolWaiting: 'Tool waiting: {tool}', + toolStarted: 'Running: {tool}', + toolConfirmation: 'Tool needs confirmation: {tool}', + toolConfirmed: 'Tool confirmed: {tool}', + toolRejected: 'Tool rejected: {tool}', + toolCompleted: 'Tool completed: {tool}', + toolFailed: 'Tool failed: {tool}', + toolCancelled: 'Tool cancelled: {tool}', + toolStateQueued: 'Queued', + toolStateWaiting: 'Waiting', + toolStateStarted: 'Running', + toolStateConfirmation: 'Needs confirmation', + toolStateConfirmed: 'Confirmed', + toolStateRejected: 'Rejected', + toolStateCompleted: 'Completed', + toolStateFailed: 'Failed', + toolStateCancelled: 'Cancelled', + detailDurationMs: 'Duration (ms)', + detailExitCode: 'Exit code', + detailMatchCount: 'Matches', + detailFileCount: 'Files', + detailEntryCount: 'Entries', + detailLineCount: 'Lines read', + detailContentLength: 'Content length', + detailTitle: 'Title', + detailQueuePosition: 'Queue position', + detailDependencies: 'Waiting for', + newEvents: 'Scroll to latest output', + 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', + decisionRequired: 'Your decision is required', + afterApprove: 'If approved', + afterReject: 'If rejected', + 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 tasks ({value})', + resumingRepository: 'Recovering failed tasks...', + repositorySerial: 'Runs serially per repository', + batchAction: 'Batch action', + resumeRepositoryTitle: 'Recover repository failures', + confirmContinue: 'Continue tasks', + resumeRepositoryMessage: 'Recover {value} paused, interrupted, or failed tasks in {repository}. One task runs at a time; the rest remain queued.', + resumeRepositoryApplied: 'Queued {value} 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: 'Stage', + goal: 'Objective', + deadline: 'deadline', + noRecentOutput: 'No new output for {duration}', + lastOutput: 'Last output {duration} ago', + updated: 'Updated {duration} ago', + openInGithub: 'Open in GitHub', + issueProgress: 'Issue progress', + currentWork: 'Current', + evidenceAndOutputs: 'Work completed', + nextAction: 'Next', + latestOutcome: 'Recent progress', + outcomeUpdated: 'Updated {duration} ago', + stageWorkspace: 'Worktree', + stageAnalysis: 'Analysis and plan', + stageImplementation: 'Implementation', + stageValidation: 'Outcome validation', + stageSettlement: 'Finish', + stagePending: 'Pending', + stageActive: 'Active', + stageComplete: 'Complete', + stageBlocked: 'Blocked', + progressMeta: '{done}/{total} stages complete', + progressPreparing: 'Preparing an isolated worktree', + progressQueued: 'Waiting for the previous repository issue', + progressAnalyzing: 'Analyzing the cause and forming an actionable plan', + progressImplementing: 'Code changes are in progress', + progressValidating: 'Validating this outcome', + progressSettling: 'Saving this stage of progress', + progressWaiting: 'Waiting for your decision', + progressRecovery: 'Execution was interrupted', + progressCompleted: 'The repair workflow is complete', + progressResolvedUpstream: 'Resolved upstream', + progressResolvedUpstreamDetail: 'The current upstream code has removed the original failure path, so no additional patch is required.', + progressIdle: 'Waiting for task progress', + evidenceWorkspace: 'The isolated workspace is ready', + evidenceProgressSaved: 'Current progress is saved and can resume after an interruption', + evidenceAnalysisReady: 'Issue analysis is complete and a repair direction is ready', + evidenceCodeChanges: 'Code changes are ready across {value} files', + evidenceCodeChangesUnknown: 'Code changes are ready', + evidenceValidationPassed: 'Project validation has run without a reported failure', + evidenceValidationNeedsAttention: 'Some validation did not pass and still needs attention', + evidenceResolvedUpstream: 'Current upstream code no longer contains the original failure path', + evidenceNoPatchRequired: 'No repair patch needs to be submitted', + evidenceNone: 'No user-facing work result is available yet', + nextPrepare: 'Finish the worktree, then analyze the issue.', + nextQueued: 'Start automatically when the repository slot is free.', + nextAnalyze: 'Finish root-cause analysis and form an actionable plan.', + nextImplement: 'Continue implementation and retain verifiable change evidence.', + nextValidate: 'Validate the current output and save progress.', + nextSettle: 'Save progress, then continue to the next stage or finish.', + nextRecover: 'Resolve the execution failure, then continue the task.', + nextApproval: 'Review the approval request and decide whether to continue.', + nextCompleted: 'Review the final outcome and outputs.', + nextResolvedUpstream: 'No further repair is required. Keep the record or archive this task.', + issueDescription: 'Issue description', + loadingIssueDescription: 'Loading issue description...', + issueDescriptionUnavailable: 'Issue description is temporarily unavailable.', + publishApprovalTitle: 'Publish the fix and create a pull request?', + publishApprovalSummary: 'The fix is prepared on branch {branch} at commit {commit} for {repository}. Your approval is required before publishing it.', + publishApprovalSummaryGeneric: 'The fix and publishing materials are ready for {repository}. Your approval is required before creating the pull request.', + publishApprovalApproveEffect: 'Push the fix branch, create a pull request, then continue with macOS host verification. Approval does not merge code automatically.', + publishApprovalRejectEffect: 'Do not push the branch or create a pull request. Keep the local branch, commit, and validation results, and stop at this step.', + publishApprovalRecommendationReady: 'Recommended: approve. The change has validation results and remains reviewable in the pull request; it will not be merged automatically.', + publishApprovalRecommendationReview: 'Review the change and validation results first. Approval publishes a pull request but does not merge it automatically.', + publishApprovalApprove: 'Approve and create PR', + publishApprovalReject: 'Keep local only', + autonomyApprovalTitle: 'The repair is not finished. Continue?', + autonomyApprovalSummaryAnalyzed: 'The issue has been analyzed and a clear direction is available, but implementation and validation are not complete.', + autonomyApprovalSummaryImplemented: 'Repair changes are available, but validation and final confirmation are not complete.', + autonomyApprovalApproveEffect: 'Continue repairing and validating, then report the result. This will not publish or merge code.', + autonomyApprovalRejectEffect: 'Pause work and preserve the current workspace, changes, and investigation results.', + autonomyApprovalRecommendation: 'Recommendation: continue. The direction is clear and the remaining work is implementation and validation.', + continueRepair: 'Continue repair', + pauseProcessing: 'Pause for now', + genericApprovalTitle: 'Allow the task to take the next step?', + genericApprovalSummary: 'The task reached an operation that requires human confirmation. Review the effects below before deciding.', + genericApprovalApproveEffect: 'Allow this operation and continue the remaining workflow.', + genericApprovalRejectEffect: 'Do not perform this operation. Preserve existing changes and progress, and stop at this step.', + genericApprovalRecommendation: 'Recommendation: approve only when the operation scope matches your expectation. Otherwise reject it and note what information is missing.', + outcomeWaiting: 'Code changes and available validation results are saved. The task is waiting for your decision before taking the next step.', + outcomeRecovery: 'Execution was interrupted. Existing investigation results and changes are preserved and can resume from the current progress.', + outcomeCompleted: 'The repair workflow is complete. Review the final outputs and validation results.', + outcomeResolvedUpstream: 'Current upstream code has removed the old failure path, so this task does not need another repair patch.', + outcomeValidated: 'The repair changes are ready and have reached validation or a follow-up operation.', + outcomeImplemented: 'Code changes are ready. Validation and final outcome work are still in progress.', + outcomeAnalyzed: 'Issue analysis is complete. The repair approach is being finalized or implemented.', + outcomeQueued: 'The task is created and waiting for its workspace or repository execution slot.', + outcomeStarted: 'The task has started. Progress will continue to update here.', + 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 runtime interface. 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.', + approvalSubmitting: 'Submitting the decision. The task will continue after host confirmation.', + approvalSubmittingShort: 'Submitting decision...', + 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.', + continueInvestigation: 'Continue investigation', + stopTask: 'Stop task', + activityInstallingDependencies: 'Preparing project dependencies', + activityBuildingInstaller: 'Building the Windows installer', + activityTestingUpgrade: 'Validating the installer upgrade path', + activityWaitingProcess: 'Waiting for an external process to finish', + activitySyncingProgress: 'Synchronizing durable progress', + activityCheckingRepository: 'Checking repository state', + activityRunningCommand: 'Running a project command', + 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 engine', + 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: 'Interrupted', + state_completed: 'Completed', + state_failed: 'Failed', + state_archived: 'Archived', + state_resolved_upstream: 'Resolved upstream', + phase_unknown: 'Waiting for host state', + phase_validating_environment: 'Validating environment', + phase_resolving_intake: 'Resolving intake', + phase_preparing_workspace: 'Preparing an isolated workspace', + phase_creating_goal: 'Preparing the task objective', + phase_queued: 'Waiting for scheduler', + phase_inspecting_goal: 'Reviewing the task objective', + phase_building_turn: 'Building turn', + phase_starting_agent: 'Starting the repair task', + phase_agent_running: 'Analyzing or modifying code', + phase_validating_progress: 'Validating results', + phase_settling_turn: 'Saving progress', + 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'), + intakeHistory: byId('intake-history'), + modelSelect: byId('model-select'), + resolveButton: byId('resolve-button'), + resetLoopx: byId('reset-loopx'), + notice: byId('notice'), + unsupportedBanner: byId('unsupported-banner'), + unsupportedReason: byId('unsupported-reason'), + approvalAlert: byId('approval-alert'), + approvalAlertTitle: byId('approval-alert-title'), + approvalAlertMessage: byId('approval-alert-message'), + approvalAlertOpen: byId('approval-alert-open'), + approvalAlertReject: byId('approval-alert-reject'), + approvalAlertApprove: byId('approval-alert-approve'), + 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'), + 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'), + issueLink: byId('issue-link'), + issueNumber: byId('issue-number'), + issueApprovalPanel: byId('issue-approval-panel'), + issueApprovalTitle: byId('issue-approval-title'), + issueApprovalSummary: byId('issue-approval-summary'), + issueApprovalApproveEffect: byId('issue-approval-approve-effect'), + issueApprovalRejectEffect: byId('issue-approval-reject-effect'), + issueApprovalRecommendation: byId('issue-approval-recommendation'), + issueApprovalNote: byId('issue-approval-note'), + issueApprovalReject: byId('issue-approval-reject'), + issueApprovalApprove: byId('issue-approval-approve'), + issueProgressPanel: byId('issue-progress-panel'), + issueProgressMeta: byId('issue-progress-meta'), + issueStageList: byId('issue-stage-list'), + issueCurrentHeading: byId('issue-current-heading'), + issueCurrentDetail: byId('issue-current-detail'), + issueEvidenceList: byId('issue-evidence-list'), + issueNextAction: byId('issue-next-action'), + issueOutcomePanel: byId('issue-outcome-panel'), + issueOutcomeMeta: byId('issue-outcome-meta'), + issueOutcome: byId('issue-outcome'), + issueDescriptionPanel: byId('issue-description-panel'), + issueDescription: byId('issue-description'), + taskActions: byId('task-actions'), + logScroll: byId('log-scroll'), + logEmpty: byId('log-empty'), + logList: byId('log-list'), + newEvents: byId('new-events'), + issueDetailDialog: byId('issue-detail-dialog'), + 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'), + 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, + followLogs: true, + preview: null, + pendingCreate: null, + pendingRetry: null, + approvalTaskId: null, + promptedGateIds: new Set(), + pendingApprovalPrompt: false, + syncing: false, + syncRequested: false, + pendingResumeSignal: false, + lastClockSampleAt: Date.now(), + lastHostSignalAt: Date.now(), + lastReattachAt: 0, + gapRecovery: null, + connected: false, + railCollapsed: false, + intakeHistory: [], + outputHistory: [], + outputKeys: new Set(), + outputCharacters: 0, + 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 isResolvedUpstream(task) { + const summary = String(task && task.lastAgentSummary || ''); + return /covered[-_ ]?upstream.{0,80}no[-_ ]?follow[-_ ]?up/is.test(summary) + || /原始故障路径.{0,40}(?:消失|移除).{0,120}(?:不开\s*PR|无需.{0,20}修复)/is.test(summary); +} + +function taskStateLabel(task) { + if (isResolvedUpstream(task)) return stateLabel('resolved_upstream'); + 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'; + if (isResolvedUpstream(task)) return 'completed'; + 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 renderIntakeHistory() { + if (!view.intakeHistory) return; + const fragment = document.createDocumentFragment(); + state.intakeHistory.forEach((url) => { + const option = document.createElement('option'); + option.value = url; + fragment.append(option); + }); + view.intakeHistory.replaceChildren(fragment); +} + +async function loadIntakeHistory() { + if (!app || !app.storage || typeof app.storage.get !== 'function') return; + try { + const stored = await app.storage.get(INTAKE_HISTORY_STORAGE_KEY); + state.intakeHistory = Array.isArray(stored) + ? stored.filter((value) => typeof value === 'string' && value.trim()).slice(0, MAX_INTAKE_HISTORY) + : []; + renderIntakeHistory(); + } catch (_error) { + state.intakeHistory = []; + } +} + +async function rememberIntake(value) { + const url = String(value || '').trim(); + if (!url) return; + state.intakeHistory = [ + url, + ...state.intakeHistory.filter((entry) => entry.toLowerCase() !== url.toLowerCase()), + ].slice(0, MAX_INTAKE_HISTORY); + renderIntakeHistory(); + if (!app || !app.storage || typeof app.storage.set !== 'function') return; + try { + await app.storage.set(INTAKE_HISTORY_STORAGE_KEY, state.intakeHistory); + } catch (_error) { + // Input history remains available for the current session. + } +} + +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 describeModelOption(model) { + const displayName = String(model.name || model.modelName || model.id || '').trim(); + const modelName = String(model.modelName || '').trim(); + const provider = String(model.provider || '').trim(); + const primary = displayName || modelName || model.id; + const details = []; + if (modelName && modelName !== primary) details.push(modelName); + if (provider && provider !== primary && provider !== modelName) details.push(provider); + return details.length > 0 ? `${primary} (${details.join(' · ')})` : primary; +} + +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' && selected.phase === 'agent_running') return selected; + const tasks = state.snapshot && Array.isArray(state.snapshot.tasks) + ? state.snapshot.tasks + : []; + return tasks.find((task) => task.state === 'running' && task.phase === 'agent_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(); + 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}`)); +} + +function clearRunUiState() { + state.events = []; + state.eventKeys.clear(); + state.selectedTaskId = null; + state.preview = null; + state.pendingCreate = null; + state.pendingRetry = null; + state.approvalTaskId = null; + state.promptedGateIds.clear(); + state.pendingApprovalPrompt = false; + state.repositoryResumeTarget = null; + state.repositoryResumePending = false; + state.taskActionPending.clear(); + state.itemMetadata.clear(); + state.metadataRequests.clear(); + state.outputHistory = []; + state.outputKeys.clear(); + state.outputCharacters = 0; + clearTurnOutputTimer(); + resetTurnOutput(null); + if (view.issueDetailDialog.open) view.issueDetailDialog.close(); +} + +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) 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); + const turnId = event.turnId || page.turnId || state.turnOutput.turnId || ''; + const outputKey = `${task.taskId}:${turnId}:${event.cursor}`; + if (!state.outputKeys.has(outputKey)) { + const rawText = event.text == null ? '' : String(event.text); + const boundedText = rawText.length <= MAX_OUTPUT_EVENT_CHARS + ? rawText + : `${rawText.slice(0, MAX_OUTPUT_EVENT_CHARS / 2)}\n...\n${rawText.slice(-MAX_OUTPUT_EVENT_CHARS / 2)}`; + state.outputKeys.add(outputKey); + state.outputHistory.push({ + ...event, + text: boundedText, + taskId: task.taskId, + turnId, + }); + state.outputCharacters += boundedText.length; + } + }); + state.turnOutput.events.sort((left, right) => left.cursor - right.cursor); + while (state.turnOutput.events.length > MAX_TURN_OUTPUT_EVENTS) { + state.turnOutput.events.shift(); + } + while ( + state.outputHistory.length > MAX_OUTPUT_HISTORY_EVENTS + || state.outputCharacters > MAX_OUTPUT_HISTORY_CHARS + ) { + const removed = state.outputHistory.shift(); + state.outputKeys.delete(`${removed.taskId}:${removed.turnId || ''}:${removed.cursor}`); + state.outputCharacters = Math.max(0, state.outputCharacters - String(removed.text || '').length); + } + 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; + const streamChanged = previousStreamId && previousStreamId !== snapshot.streamId; + state.snapshot = snapshot; + if (streamChanged) { + clearRunUiState(); + } else { + replaceStreamEvents(snapshot.streamId); + } + if (state.selectedTaskId && !taskForId(state.selectedTaskId)) { + state.selectedTaskId = null; + if (view.issueDetailDialog.open) view.issueDetailDialog.close(); + } + state.connected = true; + state.lastHostSignalAt = Date.now(); + view.connectionLabel.textContent = text('connected'); + view.root.setAttribute('aria-busy', 'false'); + renderAll(); + if (state.pendingApprovalPrompt) { + syncApprovalAttention(true); + if (currentApprovalAttention()) state.pendingApprovalPrompt = false; + } +} + +async function attachSnapshot(loadHistory = false, resumeDetected = false) { + if (!app || !app.loopx) { + showBridgeUnavailable(); + return; + } + if (resumeDetected) state.pendingResumeSignal = true; + if (state.syncing) { + state.syncRequested = true; + return; + } + state.syncing = true; + if (!view.repositoryActions.hidden) view.resumeRepository.disabled = true; + try { + do { + state.syncRequested = false; + const reportResume = state.pendingResumeSignal; + state.pendingResumeSignal = 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 } : {}), + ...(reportResume ? { resumeDetected: true } : {}), + }); + state.lastReattachAt = Date.now(); + 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(); + syncApprovalAttention(true); + } + } + } while (state.syncRequested); + } catch (error) { + state.connected = false; + view.connectionLabel.textContent = text('connectionFailed'); + showNotice(errorMessage(error), 'error'); + } finally { + state.syncing = 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; + state.lastHostSignalAt = Date.now(); + 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); + if (event.kind === 'approval_required') state.pendingApprovalPrompt = true; + state.snapshot.cursor = Math.max(cursor, event.cursor); + if (changed) { + renderLogs(); + if (event.kind === 'approval_required') syncApprovalAttention(true); + } + 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 environmentBusyOrBlocked = environmentStatus === 'checking' || environmentStatus === 'blocked'; + view.unsupportedBanner.hidden = !snapshot || supported; + if (snapshot && !supported) { + view.unsupportedReason.textContent = snapshot.unsupportedReason || text('unsupportedDefault'); + } + view.resolveButton.disabled = !supported || environmentBusyOrBlocked || state.resetPending; + 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 ERROR_TASK_STATES = new Set(['recovery_required', 'failed']); +const RECOVERABLE_TASK_STATES = new Set(['stopped', ...ERROR_TASK_STATES]); +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 taskSortPriority(task) { + if (isResolvedUpstream(task)) return 20; + 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 recoverableTasksForRepository(tasks, repository) { + const key = repositoryKey(repository); + return tasks.filter((task) => + RECOVERABLE_TASK_STATES.has(task.state) + && !isResolvedUpstream(task) + && 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 (!RECOVERABLE_TASK_STATES.has(task.state) || isResolvedUpstream(task)) 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.values()][0] || null); + const eligible = repository ? recoverableTasksForRepository(tasks, repository) : []; + state.repositoryResumeTarget = repository && eligible.length > 0 + ? { 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 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); + 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 { + taskState.classList.add('task-item__hint'); + if (visualState === 'running') taskState.classList.add('task-item__hint--running'); + taskState.textContent = taskStateDisplayLabel(task); + } + taskState.title = taskStateDisplayLabel(task); + button.setAttribute('aria-label', `${label.textContent}, ${taskStateDisplayLabel(task)}`); + const compact = document.createElement('span'); + compact.className = 'task-item__compact'; + compact.textContent = item && item.number ? `#${item.number}` : shortId(task.taskId); + button.append(main, compact, taskState); + button.addEventListener('click', () => selectTask(task.taskId)); + return button; +} + +function renderTasks() { + const tasks = state.snapshot && Array.isArray(state.snapshot.tasks) ? state.snapshot.tasks : []; + const fragment = document.createDocumentFragment(); + sortedTaskList(tasks).forEach((task) => fragment.append(taskButton(task))); + view.taskItems.replaceChildren(fragment); + view.taskCount.textContent = String(tasks.length); + view.taskEmpty.hidden = tasks.length !== 0; + renderRepositoryActions(tasks); + syncApprovalAttention(false); +} + +function latestGate(taskId) { + const task = taskForId(taskId); + if (task && task.pendingGateId) { + const actionKind = task.pendingGateActionKind || ''; + return { + gateId: task.pendingGateId, + actionKind, + event: { + message: task.pendingGateMessage || text('approvalNeeded'), + details: { + gateId: task.pendingGateId, + actionKind, + autonomousTurns: String(task.autonomousTurnsSinceReview || 4), + }, + }, + }; + } + 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, + actionKind: details.actionKind || details.action_kind || '', + }; + } + } + return null; +} + +function currentApprovalAttention() { + const tasks = state.snapshot && Array.isArray(state.snapshot.tasks) + ? sortedTaskList(state.snapshot.tasks) + : []; + const task = tasks.find((candidate) => candidate.state === 'waiting_for_user') || null; + if (!task) return null; + return { task, gate: latestGate(task.taskId) }; +} + +function gateRawMessage(gate) { + return String(gate && gate.event && gate.event.message || '').trim(); +} + +function approvalPresentation(task, gate) { + const rawMessage = gateRawMessage(gate); + const actionKind = String(gate && gate.actionKind || '').toLowerCase(); + if (actionKind === 'autonomous_budget_review') { + const evidence = taskProgressEvidence(task); + return { + title: text('autonomyApprovalTitle'), + summary: text(evidence.changes > 0 + ? 'autonomyApprovalSummaryImplemented' + : 'autonomyApprovalSummaryAnalyzed'), + approveEffect: text('autonomyApprovalApproveEffect'), + rejectEffect: text('autonomyApprovalRejectEffect'), + recommendation: text('autonomyApprovalRecommendation'), + approveLabel: text('continueRepair'), + rejectLabel: text('pauseProcessing'), + }; + } + + const publishPullRequest = actionKind.includes('publish') + || actionKind.includes('pull_request') + || /\bpr bundle\b|(?:publish|push|creat(?:e|ing|ion)).{0,100}(?:pull request|\bpr\b)/i.test(rawMessage); + if (publishPullRequest) { + const branch = rawMessage.match(/\bbranch\s+([^,\s)]+)/i)?.[1] || ''; + const commit = rawMessage.match(/\bcommit\s+([0-9a-f]{7,40})\b/i)?.[1] || ''; + const messageRepository = rawMessage.match(/\bto\s+([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)(?=;|[\s.,]|$)/i)?.[1] || ''; + const item = task && task.identity && task.identity.item; + const repository = messageRepository || repositoryLabel(item && item.repository) || '--'; + const evidence = taskProgressEvidence(task); + const validated = evidence.validated || evidence.settled || /\b(?:validated|verified)\b/i.test(rawMessage); + return { + title: text('publishApprovalTitle'), + summary: branch && commit + ? text('publishApprovalSummary', { branch, commit, repository }) + : text('publishApprovalSummaryGeneric', { repository }), + approveEffect: text('publishApprovalApproveEffect'), + rejectEffect: text('publishApprovalRejectEffect'), + recommendation: text(validated ? 'publishApprovalRecommendationReady' : 'publishApprovalRecommendationReview'), + approveLabel: text('publishApprovalApprove'), + rejectLabel: text('publishApprovalReject'), + }; + } + + return { + title: text('genericApprovalTitle'), + summary: text('genericApprovalSummary'), + approveEffect: text('genericApprovalApproveEffect'), + rejectEffect: text('genericApprovalRejectEffect'), + recommendation: text('genericApprovalRecommendation'), + approveLabel: text('approve'), + rejectLabel: text('reject'), + }; +} + +function syncApprovalAttention(autoOpen = false) { + const attention = currentApprovalAttention(); + state.approvalTaskId = attention ? attention.task.taskId : null; + view.approvalAlert.hidden = !attention; + if (!attention) return; + + const { task, gate } = attention; + const item = task.identity && task.identity.item; + const presentation = approvalPresentation(task, gate); + view.approvalAlertTitle.textContent = `${identityTitleOf(task) || itemLabel(item)} · ${text('decisionRequired')}`; + view.approvalAlertOpen.title = presentation.summary; + view.approvalAlertOpen.setAttribute('aria-label', `${presentation.title} ${presentation.summary}`); + const pending = Boolean(pendingActionFor(task)); + view.approvalAlertMessage.textContent = pending + ? text('approvalSubmitting') + : `${presentation.title} ${presentation.summary}`; + view.approvalAlertReject.textContent = presentation.rejectLabel; + view.approvalAlertApprove.textContent = pending ? text('approvalSubmittingShort') : presentation.approveLabel; + view.approvalAlertReject.disabled = pending || !gate; + view.approvalAlertApprove.disabled = pending || !gate; + + if ( + autoOpen + && gate + && !state.promptedGateIds.has(gate.gateId) + ) { + state.promptedGateIds.add(gate.gateId); + const anotherDialogOpen = [...document.querySelectorAll('dialog[open]')] + .some((dialog) => dialog !== view.issueDetailDialog); + if (!anotherDialogOpen) selectTask(task.taskId); + } +} + +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', () => { + 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 (isResolvedUpstream(task)) { + return; + } + if (['preparing', 'queued', 'running', 'retry_wait'].includes(task.state)) { + fragment.append(makeActionButton(text('pause'), 'pause', 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 safeMarkdownUrl(rawUrl, baseUrl) { + try { + const url = new URL(rawUrl, baseUrl || undefined); + return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : ''; + } catch (_error) { + return ''; + } +} + +function appendInlineMarkdown(parent, source, baseUrl) { + const pattern = /(`[^`\n]+`|\[[^\]\n]+\]\([^\s)]+\)|\*\*[^*\n]+\*\*|__[^_\n]+__)/g; + let cursor = 0; + for (const match of source.matchAll(pattern)) { + if (match.index > cursor) parent.append(document.createTextNode(source.slice(cursor, match.index))); + const token = match[0]; + if (token.startsWith('`')) { + const code = document.createElement('code'); + code.textContent = token.slice(1, -1); + parent.append(code); + } else if (token.startsWith('[')) { + const parts = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/); + const href = parts ? safeMarkdownUrl(parts[2], baseUrl) : ''; + if (parts && href) { + const link = document.createElement('a'); + link.href = href; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + link.textContent = parts[1]; + parent.append(link); + } else { + parent.append(document.createTextNode(token)); + } + } else { + const strong = document.createElement('strong'); + strong.textContent = token.slice(2, -2); + parent.append(strong); + } + cursor = match.index + token.length; + } + if (cursor < source.length) parent.append(document.createTextNode(source.slice(cursor))); +} + +function renderMarkdown(target, source, baseUrl) { + const fragment = document.createDocumentFragment(); + const lines = String(source || '').replace(/\r\n?/g, '\n').split('\n'); + let list = null; + let code = null; + let paragraph = null; + const closeParagraph = () => { paragraph = null; }; + const closeList = () => { list = null; }; + lines.forEach((line) => { + if (/^```/.test(line)) { + closeParagraph(); + closeList(); + if (code) { + code = null; + } else { + const pre = document.createElement('pre'); + code = document.createElement('code'); + pre.append(code); + fragment.append(pre); + } + return; + } + if (code) { + code.append(document.createTextNode(`${code.textContent ? '\n' : ''}${line}`)); + return; + } + if (!line.trim()) { + closeParagraph(); + closeList(); + return; + } + const heading = line.match(/^(#{1,4})\s+(.+)$/); + if (heading) { + closeParagraph(); + closeList(); + const element = document.createElement(`h${Math.min(heading[1].length + 2, 6)}`); + appendInlineMarkdown(element, heading[2], baseUrl); + fragment.append(element); + return; + } + const listItem = line.match(/^\s*(?:[-*+] |\d+\. )(.+)$/); + if (listItem) { + closeParagraph(); + if (!list) { + list = document.createElement(/^\s*\d+\./.test(line) ? 'ol' : 'ul'); + fragment.append(list); + } + const item = document.createElement('li'); + appendInlineMarkdown(item, listItem[1], baseUrl); + list.append(item); + return; + } + closeList(); + const quote = line.match(/^>\s?(.*)$/); + if (quote) { + closeParagraph(); + const element = document.createElement('blockquote'); + appendInlineMarkdown(element, quote[1], baseUrl); + fragment.append(element); + return; + } + if (!paragraph) { + paragraph = document.createElement('p'); + fragment.append(paragraph); + } else { + paragraph.append(document.createElement('br')); + } + appendInlineMarkdown(paragraph, line, baseUrl); + }); + target.replaceChildren(fragment); +} + +function progressTaskEvents(task) { + return state.events.filter((event) => ( + event.taskId === task.taskId + && (event.generation == null || Number(event.generation) === Number(task.generation)) + )); +} + +function compactArtifactPath(value) { + const parts = String(value || '').split(/[\\/]/).filter(Boolean); + return parts.slice(-3).join('/'); +} + +function isProductArtifact(value) { + const normalized = String(value || '').replace(/\\/g, '/').toLowerCase(); + if (!normalized) return false; + return !normalized.startsWith('.loopx/') + && !normalized.includes('/.loopx/') + && !normalized.startsWith('.codex/') + && !normalized.includes('/.codex/') + && !normalized.startsWith('.bitfun/') + && !normalized.includes('/.bitfun/') + && !normalized.includes('/appdata/local/temp/'); +} + +function taskProgressEvidence(task) { + const events = progressTaskEvents(task); + const started = events.filter((event) => event.details && event.details.activity === 'started'); + const countTools = (names) => started.filter((event) => names.has(event.toolName)).length; + const reads = countTools(new Set(['Read', 'LS'])); + const searches = countTools(new Set(['Grep'])); + const web = countTools(new Set(['WebFetch', 'WebSearch'])); + const changes = started.filter((event) => ( + ['Write', 'Edit', 'ApplyPatch'].includes(event.toolName) + && isProductArtifact(event.details && event.details.summary) + )); + const commands = countTools(new Set(['ExecCommand'])); + const failures = events.filter((event) => event.details && event.details.activity === 'failed').length; + const artifacts = [...new Set(changes + .map((event) => compactArtifactPath(event.details && event.details.summary)) + .filter(Boolean))].slice(-3); + const validated = events.some((event) => event.phase === 'validating_progress'); + const settled = Boolean(task.settlement && task.settlement.receiptId); + return { + reads, + searches, + web, + analysisCount: reads + searches + web, + changes: changes.length, + commands, + failures, + artifacts, + validated, + settled, + }; +} + +function progressStageStatus(task, evidence) { + if (isResolvedUpstream(task)) { + return [ + ['stageWorkspace', 'complete'], + ['stageAnalysis', 'complete'], + ['stageImplementation', 'complete'], + ['stageValidation', 'complete'], + ['stageSettlement', 'complete'], + ]; + } + const hasSummary = Boolean(task.lastAgentSummary); + const afterAgent = hasSummary || evidence.validated || evidence.settled + || ['validating_progress', 'settling_turn', 'recovering', 'finished'].includes(task.phase); + const workspace = task.workspacePath + ? 'complete' + : (task.phase === 'preparing_workspace' ? 'active' : (task.error ? 'blocked' : 'pending')); + const analysis = hasSummary + ? 'complete' + : (evidence.analysisCount > 0 + ? (afterAgent || evidence.changes > 0 ? 'complete' : 'active') + : (task.phase === 'agent_running' ? 'active' : 'pending')); + const implementation = evidence.changes > 0 + ? (afterAgent ? 'complete' : 'active') + : 'pending'; + const validation = evidence.validated || evidence.settled + ? 'complete' + : (['validating_progress', 'settling_turn'].includes(task.phase) ? 'active' : 'pending'); + let settlement = 'pending'; + if (task.state === 'completed') settlement = 'complete'; + else if (task.state === 'recovery_required' || task.state === 'failed') settlement = 'blocked'; + else if (task.phase === 'settling_turn' || task.state === 'waiting_for_user') settlement = 'active'; + return [ + ['stageWorkspace', workspace], + ['stageAnalysis', analysis], + ['stageImplementation', implementation], + ['stageValidation', validation], + ['stageSettlement', settlement], + ]; +} + +function currentProgressHeading(task, evidence) { + if (isResolvedUpstream(task)) return text('progressResolvedUpstream'); + if (task.state === 'completed') return text('progressCompleted'); + if (task.state === 'waiting_for_user') return text('progressWaiting'); + if (task.state === 'recovery_required' || task.state === 'failed') return text('progressRecovery'); + if (task.phase === 'preparing_workspace') return text('progressPreparing'); + if (task.state === 'queued' || task.phase === 'queued') return text('progressQueued'); + if (task.phase === 'validating_progress') return text('progressValidating'); + if (task.phase === 'settling_turn') return text('progressSettling'); + if (task.phase === 'agent_running') { + return text(evidence.changes > 0 ? 'progressImplementing' : 'progressAnalyzing'); + } + return text('progressIdle'); +} + +function currentProgressDetail(task, events) { + if (isResolvedUpstream(task)) return text('progressResolvedUpstreamDetail'); + if (task.state === 'queued') return latestTaskWaitReason(task); + if (task.currentTool) { + const activity = [...events].reverse().find((event) => ( + event.toolName === task.currentTool + && event.details + && event.details.activity === 'started' + )); + const summary = activity && activity.details && activity.details.summary; + return activitySummary(task.currentTool, summary); + } + if (task.state === 'recovery_required' || task.state === 'failed') return text('nextRecover'); + if (task.workspacePath && task.phase === 'creating_goal') { + return `${text('evidenceWorkspace')} · ${compactArtifactPath(task.workspacePath)}`; + } + return taskPhaseLabel(task); +} + +function activitySummary(toolName, rawSummary) { + const summary = String(rawSummary || ''); + if (toolName !== 'ExecCommand') { + const path = compactArtifactPath(summary); + return path ? `${toolLabel(toolName)} · ${path}` : toolLabel(toolName); + } + if (/yarn\s+(?:workspace\s+\S+\s+)?install|pnpm\s+install|npm\s+(?:ci|install)/i.test(summary)) { + return text('activityInstallingDependencies'); + } + if (/dist:win|electron-builder|package-win|makensis|nsis/i.test(summary)) { + return text('activityBuildingInstaller'); + } + if (/smoke-windows-installer-upgrade|installer-upgrade|overwrite-marker|repro-overwrite/i.test(summary)) { + return text('activityTestingUpgrade'); + } + if (/Start-Sleep|Wait-Process|Get-Process|Get-CimInstance/i.test(summary)) { + return text('activityWaitingProcess'); + } + if (/loopx(?:\.exe)?[^\n]*(?:refresh-state|todo|heartbeat-prompt|quota)/i.test(summary)) { + return text('activitySyncingProgress'); + } + if (/\bgit\b/i.test(summary)) return text('activityCheckingRepository'); + return text('activityRunningCommand'); +} + +function nextProgressAction(task, evidence) { + if (isResolvedUpstream(task)) return text('nextResolvedUpstream'); + if (task.state === 'completed') return text('nextCompleted'); + if (task.state === 'waiting_for_user') return text('nextApproval'); + if (task.state === 'recovery_required' || task.state === 'failed') return text('nextRecover'); + if (task.phase === 'preparing_workspace') return text('nextPrepare'); + if (task.state === 'queued' || task.phase === 'queued') return text('nextQueued'); + if (task.phase === 'validating_progress') return text('nextValidate'); + if (task.phase === 'settling_turn') return text('nextSettle'); + if (task.phase === 'agent_running') return text(evidence.changes > 0 ? 'nextImplement' : 'nextAnalyze'); + return text('nextAnalyze'); +} + +function renderIssueApproval(task) { + const gate = task && task.state === 'waiting_for_user' ? latestGate(task.taskId) : null; + view.issueApprovalPanel.hidden = !gate; + if (!gate) return; + const presentation = approvalPresentation(task, gate); + view.issueApprovalTitle.textContent = presentation.title; + view.issueApprovalSummary.textContent = presentation.summary; + view.issueApprovalApproveEffect.textContent = presentation.approveEffect; + view.issueApprovalRejectEffect.textContent = presentation.rejectEffect; + view.issueApprovalRecommendation.textContent = presentation.recommendation; + const pending = Boolean(pendingActionFor(task)); + view.issueApprovalApprove.textContent = pending ? text('approvalSubmittingShort') : presentation.approveLabel; + view.issueApprovalReject.textContent = presentation.rejectLabel; + view.issueApprovalApprove.disabled = pending; + view.issueApprovalReject.disabled = pending; + view.issueApprovalNote.disabled = pending; +} + +function issueOutcomeSummary(task, evidence) { + if (isResolvedUpstream(task)) return text('outcomeResolvedUpstream'); + if (task.state === 'waiting_for_user') return text('outcomeWaiting'); + if (task.state === 'recovery_required' || task.state === 'failed') return text('outcomeRecovery'); + if (task.state === 'completed') return text('outcomeCompleted'); + if (evidence.validated || evidence.settled) return text('outcomeValidated'); + if (evidence.changes > 0) return text('outcomeImplemented'); + if (evidence.analysisCount > 0 || String(task.lastAgentSummary || '').trim()) { + return text('outcomeAnalyzed'); + } + if (task.state === 'queued' || task.state === 'preparing') return text('outcomeQueued'); + return text('outcomeStarted'); +} + +function renderIssueProgress(task) { + const evidence = taskProgressEvidence(task); + const stages = progressStageStatus(task, evidence); + const stageFragment = document.createDocumentFragment(); + stages.forEach(([labelKey, status]) => { + const item = document.createElement('li'); + item.dataset.status = status; + const label = document.createElement('strong'); + label.textContent = text(labelKey); + const statusLabelElement = document.createElement('span'); + statusLabelElement.textContent = text(`stage${status[0].toUpperCase()}${status.slice(1)}`); + item.append(label, statusLabelElement); + stageFragment.append(item); + }); + view.issueStageList.replaceChildren(stageFragment); + view.issueProgressMeta.textContent = text('progressMeta', { + done: stages.filter(([, status]) => status === 'complete').length, + total: stages.length, + }); + + const taskEvents = progressTaskEvents(task); + view.issueCurrentHeading.textContent = currentProgressHeading(task, evidence); + view.issueCurrentDetail.textContent = currentProgressDetail(task, taskEvents); + view.issueNextAction.textContent = nextProgressAction(task, evidence); + + const facts = isResolvedUpstream(task) + ? [text('evidenceResolvedUpstream'), text('evidenceNoPatchRequired')] + : []; + if (!isResolvedUpstream(task)) { + if (evidence.validated || evidence.settled) facts.push(text('evidenceProgressSaved')); + if (task.workspacePath) facts.push(text('evidenceWorkspace')); + if (evidence.analysisCount > 0) facts.push(text('evidenceAnalysisReady')); + if (evidence.changes > 0) { + facts.push(text( + evidence.artifacts.length > 0 ? 'evidenceCodeChanges' : 'evidenceCodeChangesUnknown', + { value: evidence.artifacts.length }, + )); + } + if (evidence.commands > 0) { + facts.push(text(evidence.failures > 0 ? 'evidenceValidationNeedsAttention' : 'evidenceValidationPassed')); + } + } + if (facts.length === 0) facts.push(text('evidenceNone')); + const factFragment = document.createDocumentFragment(); + facts.slice(0, 5).forEach((fact) => { + const item = document.createElement('li'); + item.textContent = fact; + factFragment.append(item); + }); + view.issueEvidenceList.replaceChildren(factFragment); + + const outcome = issueOutcomeSummary(task, evidence); + view.issueOutcomePanel.hidden = false; + view.issueOutcomeMeta.textContent = task.lastAgentSummaryAt + ? text('outcomeUpdated', { duration: relativeLabel(task.lastAgentSummaryAt) }) + : ''; + view.issueOutcome.textContent = outcome; +} + +function renderLiveness() { + const task = selectedTask(); + view.selectedState.hidden = !task; + view.showAllEvents.hidden = !task; + if (!task) { + view.logTitle.textContent = text('liveOutput'); + view.selectedSummary.hidden = false; + view.selectedSummary.textContent = text('allTaskEvents'); + view.issueLink.hidden = true; + view.issueLink.removeAttribute('href'); + view.issueNumber.textContent = ''; + view.issueApprovalPanel.hidden = true; + view.issueProgressPanel.hidden = true; + view.issueStageList.replaceChildren(); + view.issueEvidenceList.replaceChildren(); + view.issueOutcomePanel.hidden = true; + view.issueOutcome.replaceChildren(); + view.issueDescriptionPanel.hidden = true; + view.issueDescription.replaceChildren(); + renderTaskActions(null); + return; + } + + const visualState = taskVisualState(task); + const item = task.identity && task.identity.item; + const url = itemUrl(item); + const itemLabelText = itemLabel(item); + view.logTitle.textContent = identityTitleOf(task) || itemLabelText; + view.selectedSummary.hidden = true; + view.selectedState.hidden = false; + view.selectedState.dataset.state = visualState; + view.selectedState.textContent = taskStateDisplayLabel(task); + view.issueLink.hidden = !url; + view.issueLink.textContent = itemLabelText; + if (url) { + view.issueLink.href = url; + view.issueLink.setAttribute('aria-label', `${text('openInGithub')}: ${itemLabelText}`); + } else { + view.issueLink.removeAttribute('href'); + view.issueLink.removeAttribute('aria-label'); + } + view.issueNumber.textContent = item && item.number + ? `${item.kind === 'pr' ? 'PR' : 'Issue'} #${item.number}` + : ''; + renderIssueApproval(task); + view.issueProgressPanel.hidden = false; + renderIssueProgress(task); + + 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 = false; + renderMarkdown( + view.issueDescription, + description || (descriptionUnavailable + ? text('issueDescriptionUnavailable') + : text('loadingIssueDescription')), + url, + ); + renderTaskActions(task); +} + +function eventSourceLabel(source) { + const keys = { + controller: 'sourceScheduler', + sidecar: 'sourceLoopx', + agent: 'sourceAgent', + git: 'sourceGit', + github: 'sourceGithub', + system: 'sourceSystem', + }; + return keys[source] ? text(keys[source]) : (source || text('sourceScheduler')); +} + +function toolLabel(toolName) { + const keys = { + ExecCommand: 'toolExecCommand', + Read: 'toolRead', + Grep: 'toolGrep', + LS: 'toolLs', + WebFetch: 'toolWebFetch', + WebSearch: 'toolWebSearch', + Write: 'toolWrite', + Edit: 'toolEdit', + }; + return keys[toolName] ? text(keys[toolName]) : (toolName || text('outputTool')); +} + +function toolStateLabel(stateValue) { + const key = { + queued: 'toolStateQueued', + waiting: 'toolStateWaiting', + started: 'toolStateStarted', + confirmation: 'toolStateConfirmation', + confirmed: 'toolStateConfirmed', + rejected: 'toolStateRejected', + completed: 'toolStateCompleted', + failed: 'toolStateFailed', + cancelled: 'toolStateCancelled', + }[stateValue]; + return key ? text(key) : stateValue; +} + +function eventMessage(event) { + const activity = event.details && event.details.activity; + const key = { + queued: 'toolQueued', + waiting: 'toolWaiting', + started: 'toolStarted', + confirmation: 'toolConfirmation', + confirmed: 'toolConfirmed', + rejected: 'toolRejected', + completed: 'toolCompleted', + failed: 'toolFailed', + cancelled: 'toolCancelled', + }[activity]; + if (key) { + return text(key, { tool: toolLabel(event.toolName || event.details.toolName) }); + } + return event.message || event.kind || 'event'; +} + +function eventDetailLabel(key) { + const keys = { + durationMs: 'detailDurationMs', + exitCode: 'detailExitCode', + matchCount: 'detailMatchCount', + fileCount: 'detailFileCount', + entryCount: 'detailEntryCount', + lineCount: 'detailLineCount', + contentLength: 'detailContentLength', + title: 'detailTitle', + queuePosition: 'detailQueuePosition', + dependencies: 'detailDependencies', + }; + return keys[key] ? text(keys[key]) : key; +} + +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 = eventSourceLabel(event.source); + + const content = document.createElement('div'); + content.className = 'log-content'; + const message = document.createElement('div'); + message.className = 'log-message'; + message.textContent = eventMessage(event); + content.append(message); + + const toolSummary = event.details && event.details.summary; + if (toolSummary) { + const summary = document.createElement('div'); + summary.className = 'log-tool-summary'; + summary.textContent = String(toolSummary); + content.append(summary); + } + + 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')}: ${toolLabel(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 || {}) + .filter(([key]) => !['activity', 'toolName', 'summary'].includes(key)); + 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 = eventDetailLabel(key); + const description = document.createElement('dd'); + description.textContent = String(value); + list.append(term, description); + }); + details.append(summary, list); + content.append(details); + } + + 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 = eventSourceLabel('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; + const combined = existing ? `${existing}${value}` : value; + if (combined.length <= MAX_OUTPUT_BLOCK_CHARS) return combined; + return `${combined.slice(0, MAX_OUTPUT_BLOCK_CHARS / 2)}\n...\n${combined.slice(-MAX_OUTPUT_BLOCK_CHARS / 2)}`; +} + +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.taskId === event.taskId + && last.turnId === event.turnId + && !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, + taskId: event.taskId || '', + turnId: event.turnId || '', + kind, + roundId, + toolName, + toolState: event.toolState || '', + text: outputEventFallbackText(event), + isEnd: Boolean(event.isEnd), + eventCount: 1, + }); + }); + return blocks; +} + +function cursorRangeLabel(block) { + return block.startCursor === block.endCursor + ? `#${block.startCursor}` + : `#${block.startCursor}-${block.endCursor}`; +} + +function outputBlockDomKey(block) { + return `${block.taskId}:${block.turnId}:${block.kind}:${block.startCursor}`; +} + +function outputBlockDomVersion(block) { + return `${block.endCursor}:${block.eventCount}:${block.toolState}:${String(block.text || '').length}`; +} + +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); + row.dataset.taskId = block.taskId; + row.dataset.blockKey = outputBlockDomKey(block); + row.dataset.blockVersion = outputBlockDomVersion(block); + + const header = document.createElement('div'); + header.className = 'output-block__header'; + + const task = taskForId(block.taskId); + const item = task && task.identity && task.identity.item; + const issue = document.createElement(itemUrl(item) ? 'a' : 'span'); + issue.className = 'output-block__issue'; + issue.textContent = item ? compactItemLabel(item) : text('taskNumber', { value: shortId(block.taskId) }); + if (issue.tagName === 'A') { + issue.href = itemUrl(item); + issue.target = '_blank'; + issue.rel = 'noopener noreferrer'; + issue.title = identityTitleOf(task) || itemLabel(item); + } + + 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 ? toolLabel(block.toolName) : eventSourceLabel('agent'); + + const cursor = document.createElement('span'); + cursor.className = 'output-block__cursor'; + cursor.textContent = cursorRangeLabel(block); + + header.append(issue, 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 = toolStateLabel(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.outputHistory); + const visible = blocks.slice(-MAX_RENDERED_OUTPUT_BLOCKS); + const existing = new Map( + [...view.logList.children] + .filter((node) => node.dataset && node.dataset.blockKey) + .map((node) => [node.dataset.blockKey, node]), + ); + const desired = visible.map((block) => { + const key = outputBlockDomKey(block); + const node = existing.get(key); + return node && node.dataset.blockVersion === outputBlockDomVersion(block) + ? node + : turnOutputBlockRow(block); + }); + desired.forEach((node, index) => { + const current = view.logList.children[index]; + if (current !== node) view.logList.insertBefore(node, current || null); + }); + const desiredNodes = new Set(desired); + [...view.logList.children].forEach((node) => { + if (!desiredNodes.has(node)) node.remove(); + }); + const hasOutput = visible.length !== 0; + view.logEmpty.hidden = hasOutput; + if (!hasOutput) { + const message = state.turnOutput.message || text('noLiveOutput'); + 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() { + renderTurnOutput(); +} + +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) { + const changed = state.selectedTaskId !== (taskId || null); + state.selectedTaskId = taskId || null; + if (changed) view.issueApprovalNote.value = ''; + renderTasks(); + renderLiveness(); + renderLogs(); + if (taskId) { + if (!view.issueDetailDialog.open) view.issueDetailDialog.showModal(); + void hydrateTaskMetadata(taskId); + } else if (view.issueDetailDialog.open) { + view.issueDetailDialog.close(); + } +} + +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'; + const availableModels = Array.isArray(models) ? models : []; + let renderedModelCount = 0; + for (const model of availableModels) { + if (!model || !model.id) continue; + const option = document.createElement('option'); + option.value = model.id; + const tag = model.isDefault === true ? ` · ${text('modelPrimaryTag')}` : ''; + option.textContent = `${describeModelOption(model)}${tag}`; + option.selected = current === model.id; + selectedExists = selectedExists || option.selected; + select.appendChild(option); + renderedModelCount += 1; + } + if (select.options.length === 1) { + const empty = document.createElement('option'); + empty.value = ''; + empty.textContent = text('modelEmpty'); + empty.disabled = true; + empty.dataset.status = 'empty'; + select.appendChild(empty); + } + if (!selectedExists) select.value = 'auto'; + state.modelCatalogLoaded = renderedModelCount > 0; + select.title = text('modelReloadTitle'); + } catch (error) { + const current = currentModelSelection(); + [...select.options].forEach((option) => { + if (option.dataset.status) option.remove(); + }); + 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'); + } + if (![...select.options].some((option) => option.dataset.status === 'load-failed')) { + const failed = document.createElement('option'); + failed.value = ''; + failed.textContent = text('modelLoadFailed'); + failed.disabled = true; + failed.dataset.status = 'load-failed'; + select.appendChild(failed); + } + 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')); + await rememberIntake(input); + 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); + const focusedTaskId = outcomes + .find((outcome) => outcome.taskId && ['created', 'opened_existing'].includes(outcome.kind)) + ?.taskId; + state.followLogs = true; + selectTask(focusedTaskId || null); + } 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; + view.resetLoopxDialog.close(); + view.root.setAttribute('aria-busy', 'true'); + showNotice(text('resettingLoopxBackground')); + renderExecutionSupport(); + try { + const clientRequestId = requestId(); + await attachSnapshot(false); + view.root.setAttribute('aria-busy', 'true'); + 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 { + clearRunUiState(); + showNotice(text('resetLoopxApplied'), 'success'); + } + await attachSnapshot(false); + } catch (error) { + showNotice(errorMessage(error), 'error'); + await attachSnapshot(false); + } finally { + state.resetPending = false; + setButtonBusy(view.resetLoopx, false); + view.resetLoopxConfirm.disabled = false; + view.root.setAttribute('aria-busy', 'false'); + renderExecutionSupport(); + } +} + +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(); + } + } +} + +async function answerTaskGate(task, action, note = '') { + const gate = task && latestGate(task.taskId); + if (!task || !gate) { + showNotice(text('noGate'), 'error'); + return; + } + showNotice(text('approvalSubmitting')); + try { + const applied = await performAction(action, task, { gateId: gate.gateId, note: note.trim() }); + if (applied && state.selectedTaskId === task.taskId) view.issueApprovalNote.value = ''; + } finally { + syncApprovalAttention(false); + } +} + +function approvalAlertGate() { + const task = state.approvalTaskId ? taskForId(state.approvalTaskId) : null; + const gate = task ? latestGate(task.taskId) : null; + return task && gate ? { task, gate } : null; +} + +function openApprovalAlertGate() { + const attention = approvalAlertGate(); + if (attention) selectTask(attention.task.taskId); +} + +function answerApprovalAlertGate(action) { + const attention = approvalAlertGate(); + if (!attention) return; + void answerTaskGate(attention.task, action); +} + +function answerSelectedTaskGate(action) { + const task = selectedTask(); + if (!task) return; + void answerTaskGate(task, action, view.issueApprovalNote.value); +} + +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 setRailCollapsed(collapsed) { + state.railCollapsed = Boolean(collapsed); + 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'), + ); +} + +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))); + splitter.setAttribute('aria-valuenow', String(width())); + 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) { + setRailCollapsed(false); + setRailWidth(Math.max(RAIL_MIN_WIDTH, width())); + } + 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) setRailCollapsed(false); + setRailWidth(RAIL_DEFAULT_WIDTH); + splitter.setAttribute('aria-valuenow', String(width())); + persistRailWidth(); + }); + splitter.addEventListener('keydown', (event) => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); + if (state.railCollapsed) setRailCollapsed(false); + 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))); + splitter.setAttribute('aria-valuenow', String(width())); + 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.retryEnvironment.addEventListener('click', async () => { + await performAction('retry_environment', null); + }); + view.resumeRepository.addEventListener('click', openRepositoryResumeDialog); + view.approvalAlertOpen.addEventListener('click', openApprovalAlertGate); + view.approvalAlertApprove.addEventListener('click', () => answerApprovalAlertGate('approve')); + view.approvalAlertReject.addEventListener('click', () => answerApprovalAlertGate('reject')); + view.issueApprovalApprove.addEventListener('click', () => answerSelectedTaskGate('approve')); + view.issueApprovalReject.addEventListener('click', () => answerSelectedTaskGate('reject')); + view.repositoryResumeCancel.addEventListener('click', () => { + view.repositoryResumeDialog.close(); + }); + view.repositoryResumeConfirm.addEventListener('click', (event) => { + event.preventDefault(); + void resumeRepository(); + }); + view.collapseTasks.addEventListener('click', () => { + setRailCollapsed(!state.railCollapsed); + }); + view.showAllEvents.addEventListener('click', () => selectTask(null)); + view.issueDetailDialog.addEventListener('cancel', (event) => { + event.preventDefault(); + selectTask(null); + }); + 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(); + }); +} + +function updateLivenessClock() { + renderLiveness(); + renderTasks(); + const resumed = sampleHostClock(); + if (resumed) { + void attachSnapshot(false, true); + return; + } + const tasks = state.snapshot && Array.isArray(state.snapshot.tasks) ? state.snapshot.tasks : []; + const hasActiveWork = tasks.some((task) => [ + 'preparing', + 'running', + 'cancelling', + 'retry_wait', + ].includes(task.state)); + const now = Date.now(); + if ( + hasActiveWork + && document.visibilityState === 'visible' + && now - state.lastHostSignalAt >= STALE_ACTIVE_REATTACH_MS + && now - state.lastReattachAt >= STALE_ACTIVE_REATTACH_MS + ) { + void attachSnapshot(false); + } +} + +function sampleHostClock() { + const now = Date.now(); + const elapsed = now - state.lastClockSampleAt; + state.lastClockSampleAt = now; + return elapsed >= HOST_RESUME_GAP_MS; +} + +function handleHostSurfaceReturn() { + if (document.visibilityState !== 'visible') return; + void attachSnapshot(false, sampleHostClock()); +} + +async function start() { + bindEvents(); + applyLocale(); + void loadIntakeHistory(); + 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(handleHostSurfaceReturn); + document.addEventListener('visibilitychange', handleHostSurfaceReturn); + window.addEventListener('focus', handleHostSurfaceReturn); + window.addEventListener('pageshow', handleHostSurfaceReturn); + window.addEventListener('online', handleHostSurfaceReturn); + window.addEventListener('beforeunload', () => { + clearTurnOutputTimer(); + document.removeEventListener('visibilitychange', handleHostSurfaceReturn); + window.removeEventListener('focus', handleHostSurfaceReturn); + window.removeEventListener('pageshow', handleHostSurfaceReturn); + window.removeEventListener('online', handleHostSurfaceReturn); + if (app.loopx && typeof app.loopx.offEvent === 'function') { + app.loopx.offEvent(onLoopxEvent); + } + }); + window.setInterval(updateLivenessClock, HOST_CLOCK_TICK_MS); + 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/compiler.rs b/src/crates/contracts/product-domains/src/miniapp/compiler.rs index 33f1a9f879..ced578906e 100644 --- a/src/crates/contracts/product-domains/src/miniapp/compiler.rs +++ b/src/crates/contracts/product-domains/src/miniapp/compiler.rs @@ -1,8 +1,8 @@ //! MiniApp compiler — assemble source (html/css/ui_js) + import map + runtime bridge. use crate::miniapp::bridge_builder::{ - build_bridge_script, build_csp_content, build_import_map, build_market_csp_content, - build_miniapp_default_appearance_css, scroll_boundary_script, + build_bridge_script, build_csp_content, build_import_map, build_market_bridge_script, + build_market_csp_content, build_miniapp_default_appearance_css, scroll_boundary_script, }; use crate::miniapp::lifecycle::workspace_dir_string; use crate::miniapp::types::{MiniAppPermissions, MiniAppSource}; @@ -96,13 +96,23 @@ fn compile_internal( "linux" }; - let bridge = build_bridge_script( - app_id, - app_data_dir, - workspace_dir, - appearance_mode, - platform, - ); + let bridge = if market_strict { + build_market_bridge_script( + app_id, + app_data_dir, + workspace_dir, + appearance_mode, + platform, + ) + } else { + build_bridge_script( + app_id, + app_data_dir, + workspace_dir, + appearance_mode, + platform, + ) + }; let csp = if market_strict { build_market_csp_content().to_string() } else { @@ -437,4 +447,28 @@ mod tests { }); assert!(compile_market_with_request(&source, &permissions, &request).is_err()); } + + #[test] + fn market_compile_never_injects_private_builtin_extensions() { + let source = MiniAppSource { + html: "".to_string(), + css: String::new(), + ui_js: String::new(), + esm_dependencies: vec![], + worker_js: String::new(), + npm_dependencies: vec![], + }; + let permissions = MiniAppPermissions { + node: Some(crate::miniapp::types::NodePermissions { + enabled: false, + ..Default::default() + }), + ..Default::default() + }; + let request = + MiniAppCompileRequest::from_paths("builtin-bitfun-loopx", "/tmp/app", None, "dark"); + + let compiled = compile_market_with_request(&source, &permissions, &request).unwrap(); + assert!(!compiled.contains("loopx.attach")); + } } diff --git a/src/crates/contracts/product-domains/src/miniapp/loopx/bridge.rs b/src/crates/contracts/product-domains/src/miniapp/loopx/bridge.rs new file mode 100644 index 0000000000..cec0a56455 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/loopx/bridge.rs @@ -0,0 +1,22 @@ +use super::types::LOOPX_BUILTIN_APP_ID; + +/// Private runtime extension for the verified built-in LoopX product surface. +/// Ordinary and marketplace MiniApps never receive this source. +pub(crate) fn private_bridge_extension(app_id: &str) -> Option<&'static str> { + (app_id == LOOPX_BUILTIN_APP_ID).then_some( + r#"// Private product extension for the verified built-in LoopX surface. + // The outer bridge and Desktop host independently verify active scope, + // bundled source identity, customization origin, and execution domain. + 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), + },"#, + ) +} 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..97837b89ee --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/loopx/mod.rs @@ -0,0 +1,11 @@ +//! LoopX MiniApp contracts, typed service port, and pure lifecycle policy. + +mod bridge; +pub mod policy; +pub mod ports; +pub mod types; + +pub(crate) use bridge::private_bridge_extension; +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..ae448b6fb2 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/loopx/policy.rs @@ -0,0 +1,541 @@ +//! Pure parsing and lifecycle decisions for LoopX tasks. + +use super::types::{ + LoopxActionStatus, LoopxCliGoalState, LoopxCoreEnvironmentFacts, LoopxEnvironmentFactStatus, + LoopxEnvironmentStatus, LoopxEventsPageStatus, LoopxExistingTask, LoopxIntakeCandidate, + LoopxIntakeTarget, LoopxIssueKey, LoopxItemKind, LoopxOptionalEnvironmentFacts, + LoopxPermissionScope, LoopxPhase, 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, Copy, PartialEq, Eq)] +pub struct LoopxGoalProjection { + pub state: LoopxTaskState, + pub phase: LoopxPhase, +} + +/// Reconcile BitFun's local host-job projection with the authoritative LoopX +/// Goal lifecycle. Explicit local operator states and in-flight host work are +/// preserved; terminal or user-gate facts from LoopX replace stale projections. +pub fn project_host_task_from_goal( + current_state: LoopxTaskState, + current_phase: LoopxPhase, + goal_state: LoopxCliGoalState, +) -> LoopxGoalProjection { + if matches!( + current_state, + LoopxTaskState::Stopped + | LoopxTaskState::Aborted + | LoopxTaskState::Archived + | LoopxTaskState::Running + | LoopxTaskState::Cancelling + ) { + return LoopxGoalProjection { + state: current_state, + phase: current_phase, + }; + } + + match goal_state { + LoopxCliGoalState::Completed => LoopxGoalProjection { + state: LoopxTaskState::Completed, + phase: LoopxPhase::Finished, + }, + LoopxCliGoalState::Failed => LoopxGoalProjection { + state: LoopxTaskState::Failed, + phase: LoopxPhase::Finished, + }, + LoopxCliGoalState::Archived => LoopxGoalProjection { + state: LoopxTaskState::Archived, + phase: LoopxPhase::Finished, + }, + LoopxCliGoalState::WaitingForUser => LoopxGoalProjection { + state: LoopxTaskState::WaitingForUser, + phase: LoopxPhase::WaitingForApproval, + }, + LoopxCliGoalState::Active + if matches!( + current_state, + LoopxTaskState::Completed | LoopxTaskState::Failed + ) => + { + LoopxGoalProjection { + state: LoopxTaskState::RecoveryRequired, + phase: LoopxPhase::Recovering, + } + } + LoopxCliGoalState::Unknown | LoopxCliGoalState::Active => LoopxGoalProjection { + state: current_state, + phase: current_phase, + }, + } +} + +#[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..f59839ff15 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/loopx/ports.rs @@ -0,0 +1,864 @@ +//! Narrow service boundary for the pinned LoopX CLI adapter. + +use super::types::{ + LoopxCliGoalState, 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 LoopxCliCreateUserGateRequest { + #[serde(flatten)] + pub context: LoopxCliGoalContext, + pub goal_id: String, + pub agent_id: String, + pub message: String, + pub action_kind: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliCreateUserGateResult { + pub goal_id: String, + pub gate_id: String, + pub created: bool, + pub durable_revision: String, + pub settlement_receipt_count: u32, +} + +#[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 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, + pub goal_state: LoopxCliGoalState, + pub settlement_receipt_count: u32, +} + +#[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, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliResetGoalsRequest { + #[serde(flatten)] + pub call: LoopxCliCallContext, + pub goal_ids: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliResetGoalsResult { + pub requested_goal_ids: Vec, + pub retired_goal_ids: Vec, + pub already_absent_goal_ids: Vec, + pub archived_goal_ids: Vec, + pub missing_runtime_goal_ids: Vec, + pub backup_paths: Vec, + pub archive_paths: Vec, +} + +/// 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>; + + /// Adds one typed, agent-scoped owner review gate. Implementations own the + /// CLI translation; callers cannot inject raw todo arguments. + fn create_user_gate<'a>( + &'a self, + request: LoopxCliCreateUserGateRequest, + progress: &'a dyn LoopxCliProgressSink, + ) -> LoopxCliFuture<'a, LoopxCliCreateUserGateResult>; + + 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>; + + /// Finalize one external-host turn from LoopX-owned durable writeback. + /// Implementations may append the exact idempotent quota receipt required + /// by a matching typed settlement identity, but must never manufacture the + /// Agent's progress or durable writeback. + fn finalize_turn_settlement<'a>( + &'a self, + request: LoopxCliSettleTurnRequest, + progress: &'a dyn LoopxCliProgressSink, + ) -> LoopxCliFuture<'a, LoopxCliSettleTurnResult>; + + /// Retires explicit global routes and archives their runtime state after + /// an explicit product reset has removed the corresponding workspaces. + fn reset_goals<'a>( + &'a self, + request: LoopxCliResetGoalsRequest, + progress: &'a dyn LoopxCliProgressSink, + ) -> LoopxCliFuture<'a, LoopxCliResetGoalsResult>; + + 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 { + /// Task worktrees were detached from the active root. Implementations may + /// reclaim them asynchronously and retain verified bare Git object caches. + 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..963c5bc471 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/loopx/types.rs @@ -0,0 +1,760 @@ +//! 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, +} + +/// Authoritative Goal lifecycle projected from the LoopX CLI. This is kept +/// separate from [`LoopxTaskState`], which describes BitFun's local host job +/// (workspace, Agent session, cancellation, and recovery lifecycle). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxCliGoalState { + #[default] + Unknown, + Active, + WaitingForUser, + Completed, + Failed, + Archived, +} + +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, + /// Read-only Goal lifecycle from LoopX. Legacy records leave this absent + /// until the host reconciles them against the CLI. + pub goal_state: Option, + pub agent_id: Option, + /// BitFun host-job lifecycle; this is not the Goal authority. + #[serde(alias = "status")] + pub state: LoopxTaskState, + pub phase: LoopxPhase, + /// Durable answerable gate projection. Event history may be truncated, so + /// interactive approval surfaces must not depend on replay to recover it. + pub pending_gate_id: Option, + pub pending_gate_message: Option, + pub pending_gate_action_kind: Option, + 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, + /// Bounded final response from the latest Agent turn. It is persisted + /// before settlement so recovery surfaces retain the useful outcome even + /// when quota finalization fails. Empty for legacy tasks and active turns + /// that have not produced a final response yet. + pub last_agent_summary: Option, + pub last_agent_summary_at: Option, + pub deadline_at: Option, + pub retry_at: Option, + pub error: Option, + pub settlement: LoopxSettlementSummary, + /// Number of successfully settled autonomous turns since the latest + /// explicit owner decision. Legacy records default to zero. + pub autonomous_turns_since_review: u32, + /// Total LoopX settlement receipts observed when the latest owner review + /// baseline was established. Legacy records default to zero, so existing + /// long-running Goals are evaluated from their authoritative history. + pub autonomy_review_baseline_receipts: u32, + 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, + /// Set only when the trusted MiniApp detects a wall-clock discontinuity + /// consistent with host suspend/resume. Legacy clients omit it. + pub resume_detected: bool, +} + +#[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, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legacy_task_snapshot_defaults_agent_summary_fields() { + let task: LoopxTaskSnapshot = serde_json::from_value(serde_json::json!({ + "taskId": "legacy-task", + "state": "queued", + "phase": "queued" + })) + .expect("legacy task snapshot"); + + assert_eq!(task.last_agent_summary, None); + assert_eq!(task.last_agent_summary_at, None); + assert_eq!(task.autonomous_turns_since_review, 0); + assert_eq!(task.autonomy_review_baseline_receipts, 0); + assert_eq!(task.pending_gate_id, None); + assert_eq!(task.pending_gate_message, None); + assert_eq!(task.pending_gate_action_kind, None); + } + + #[test] + fn legacy_attach_request_defaults_resume_signal() { + let request: LoopxAttachRequest = serde_json::from_value(serde_json::json!({ + "knownStreamId": "stream-1", + "afterCursor": 4 + })) + .expect("legacy attach request"); + + assert!(!request.resume_detected); + } +} 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..f9ab78c701 --- /dev/null +++ b/src/crates/contracts/product-domains/tests/loopx_contracts.rs @@ -0,0 +1,569 @@ +#![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, project_host_task_from_goal, + task_state_after_restart, LoopxActionKind, LoopxActionRequest, LoopxActionStatus, + LoopxAgentFinishRequest, LoopxAgentPort, LoopxAgentStartRequest, LoopxCliAnswerGateRequest, + LoopxCliGateDecision, LoopxCliGoalSnapshot, LoopxCliGoalState, LoopxCliHandshakeRequest, + LoopxCliPort, LoopxCoreEnvironmentFacts, LoopxCreateTaskRequest, LoopxDedupDecision, + LoopxEnvironmentFact, LoopxEnvironmentFactStatus, LoopxEnvironmentStatus, + LoopxEventsPageStatus, LoopxExistingTask, LoopxIntakeCandidate, LoopxIntakeParseErrorKind, + LoopxIntakePreview, LoopxIntakeTarget, LoopxIssueKey, LoopxItemKind, + LoopxOptionalEnvironmentFacts, LoopxPermissionScope, LoopxPhase, 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 authoritative_goal_projection_preserves_explicit_host_stops() { + let stopped = project_host_task_from_goal( + LoopxTaskState::Stopped, + LoopxPhase::Finished, + LoopxCliGoalState::Completed, + ); + assert_eq!(stopped.state, LoopxTaskState::Stopped); + + let completed = project_host_task_from_goal( + LoopxTaskState::RecoveryRequired, + LoopxPhase::Recovering, + LoopxCliGoalState::Completed, + ); + assert_eq!(completed.state, LoopxTaskState::Completed); + assert_eq!(completed.phase, LoopxPhase::Finished); + + let reopened = project_host_task_from_goal( + LoopxTaskState::Completed, + LoopxPhase::Finished, + LoopxCliGoalState::Active, + ); + assert_eq!(reopened.state, LoopxTaskState::RecoveryRequired); + assert_eq!(reopened.phase, LoopxPhase::Recovering); + + let pending_approval = project_host_task_from_goal( + LoopxTaskState::WaitingForUser, + LoopxPhase::WaitingForApproval, + LoopxCliGoalState::Active, + ); + assert_eq!(pending_approval.state, LoopxTaskState::WaitingForUser); + assert_eq!(pending_approval.phase, LoopxPhase::WaitingForApproval); +} + +#[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/contracts/product-domains/tests/miniapp_contracts.rs b/src/crates/contracts/product-domains/tests/miniapp_contracts.rs index d60fd0feeb..878d249e2b 100644 --- a/src/crates/contracts/product-domains/tests/miniapp_contracts.rs +++ b/src/crates/contracts/product-domains/tests/miniapp_contracts.rs @@ -591,6 +591,23 @@ fn miniapp_bridge_exposes_topic_session_lifecycle() { assert!(bridge.contains("agent.ensureSession")); assert!(bridge.contains("chat.focusSession")); assert!(bridge.contains("chat.clearSession")); + assert!(!bridge.contains("loopx.attach")); +} + +#[test] +fn miniapp_bridge_injects_loopx_only_for_the_builtin_identity() { + let ordinary = build_bridge_script("app-1", "/tmp/app", "/tmp/workspace", "dark", "win32"); + let builtin = build_bridge_script( + "builtin-bitfun-loopx", + "/tmp/app", + "/tmp/workspace", + "dark", + "win32", + ); + + assert!(!ordinary.contains("loopx.attach")); + assert!(builtin.contains("loopx.attach")); + assert!(builtin.contains("Private product extension")); } #[test] diff --git a/src/crates/services/services-integrations/AGENTS.md b/src/crates/services/services-integrations/AGENTS.md index e28d8809a6..3794d86980 100644 --- a/src/crates/services/services-integrations/AGENTS.md +++ b/src/crates/services/services-integrations/AGENTS.md @@ -75,6 +75,22 @@ slices that are outside pure product logic but still platform-neutral. writes, marker IO, storage/import bundle filesystem IO, and JS worker process/pool lifecycle. Manager workflow orchestration remains outside this crate until reviewed owner migration. +- LoopX v0.5.1 `turn plan --include-transaction-detail` is a prospective plan, + not a completed-receipt query. External-host settlement must verify the + exact goal/agent/Todo/turn identity through the supported compact `history` + projection and require both typed `validated_progress` and the matching + `quota_slot_spent` event. A legacy `validated_progress` without + `progress_observation` is accountable only when it carries an explicit + progress delivery outcome. After exact validated progress, the host may + idempotently repair a missing turn-scoped quota spend and must re-read + history before accepting settlement. Do not infer success from the next turn + plan or fabricate progress when no matching validation exists. +- WebFetch treats HTTP 401, 403, and 429 as structured access restrictions so + the Agent can change routes without presenting an expected server policy as + a tool crash. Preserve retry/rate-limit headers, prohibit blind same-URL + retries in the result guidance, and route authentication through an existing + browser or provider boundary; never bypass access controls in the HTTP + provider. - Managed plugin source integration may own bounded package discovery, integrity checks, fixed package input reads, no-follow path handling, trust-file locking, and atomic persistence. Product path selection stays in 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..de8fc32bfb --- /dev/null +++ b/src/crates/services/services-integrations/src/miniapp/loopx_cli.rs @@ -0,0 +1,3643 @@ +//! 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, BTreeSet, 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); +const SETTLEMENT_HISTORY_LIMIT: &str = "100"; +const AGENT_CLI_USAGE_RULES: &str = "LoopX CLI syntax contract:\n- Put the global `--format json` option immediately after the exact executable and before the subcommand. Do not append it after a subcommand unless that subcommand's help explicitly lists a local format option.\n- Use `--agent-id` only on subcommands whose `--help` output lists it. Do not move that option between a parent command and its child action.\n- When a command rejects an argument, inspect that exact subcommand's `--help` once and retry with the documented form; do not repeat the rejected form."; + +#[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 run_global_json_command( + &self, + operation_id: &str, + 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")]; + args.extend(command_args); + let output = self + .runner + .run( + LoopxCommandPlan { + operation_id: operation_id.to_string(), + executable: verified.executable, + args, + current_dir: None, + 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 create_user_gate<'a>( + &'a self, + request: loopx_contract::LoopxCliCreateUserGateRequest, + progress: &'a dyn loopx_contract::LoopxCliProgressSink, + ) -> loopx_contract::LoopxCliFuture<'a, loopx_contract::LoopxCliCreateUserGateResult> { + 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, + "Creating a bounded LoopX owner review gate", + ); + let result = run_port_command( + self, + &request.context, + add_user_gate_args(&request)?, + &observer, + ) + .await?; + require_payload_ok(&result.payload, operation_id)?; + let gate_id = required_json_string(&result.payload, "todo_id", operation_id)?; + let created = result + .payload + .get("added") + .and_then(Value::as_bool) + .unwrap_or(false); + 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 snapshot = + project_goal_snapshot(&request.goal_id, &inspection.payload, operation_id)?; + let settlement_receipt_count = snapshot.settlement_receipt_ids.len() as u32; + Ok(loopx_contract::LoopxCliCreateUserGateResult { + goal_id: request.goal_id, + gate_id, + created, + durable_revision: snapshot.durable_revision, + settlement_receipt_count, + }) + }) + } + + 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_binding = planned_settlement_binding(&plan.payload); + let settlement_token = planned_settlement_token( + &plan.payload, + &request.goal_id, + &request.agent_id, + &turn_id, + operation_id, + )?; + 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 registry = agent_shell_value(&request.context.registry_path); + let cli_prefix = format!("{command} --format json --registry {registry}"); + let adapted_prompt = raw_prompt + .replace("${LOOPX_TURN:?}", &turn_id) + .replace( + "loopx --format json --registry \"$HOME/.codex/loopx/registry.global.json\" ", + &format!("{cli_prefix} "), + ) + .replace("loopx ", &format!("{cli_prefix} ")); + let settlement_override = settlement_prompt_override( + &cli_prefix, + &request.goal_id, + &request.agent_id, + &turn_id, + settlement_binding.as_ref(), + ); + let prompt = format!( + "LoopX host binding for this bounded turn:\n- Repository: {}\n- Registry: {}\n- Exact CLI prefix: {}\n- Turn id: {}\nUse this immutable binding for every LoopX command. Do not probe PATH, switch registries, invent a Turn id, infer state from prose, or substitute host-specific policy. Query the exact CLI prefix when fresh LoopX state is required; the generated task body below remains the authority for work selection.\n{}\n\n{}\n\n{}", + request.context.worktree_path, + request.context.registry_path, + cli_prefix, + turn_id, + AGENT_CLI_USAGE_RULES, + adapted_prompt, + settlement_override, + ); + 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)?; + let snapshot = + project_goal_snapshot(&request.goal_id, &inspection.payload, operation_id)?; + let settlement_receipt_count = snapshot.settlement_receipt_ids.len() as u32; + Ok(loopx_contract::LoopxCliAnswerGateResult { + goal_id: request.goal_id, + gate_id: request.gate_id, + applied: true, + durable_revision: snapshot.durable_revision, + goal_state: snapshot.state, + settlement_receipt_count, + }) + }) + } + + fn finalize_turn_settlement<'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 durable LoopX progress and quota settlement evidence", + ); + 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 mut snapshot = + project_goal_snapshot(&request.goal_id, &inspection.payload, operation_id)?; + if let Some(receipt) = + matching_settlement_receipt(&inspection.payload, &request.settlement_token) + { + return project_legacy_settlement(&request, &snapshot, receipt, operation_id); + } + + let mut history = run_port_command( + self, + &request.context, + settlement_history_args(&request.goal_id), + &observer, + ) + .await?; + require_payload_ok(&history.payload, operation_id)?; + let mut evidence = matching_durable_progress( + &history.payload, + &request.goal_id, + &request.agent_id, + &request.turn_id, + &request.settlement_token, + operation_id, + )?; + if evidence + .as_ref() + .is_some_and(|evidence| !evidence.quota_spent) + && request.agent_status == loopx_contract::LoopxAgentTurnStatus::Completed + { + let binding = evidence.as_ref().expect("checked above").binding.clone(); + report_port_progress( + progress, + operation_id, + Some(request.context.task_id.clone()), + loopx_contract::LoopxCliProgressStage::SettlingTurn, + "Finalizing LoopX quota accounting for validated durable writeback", + ); + let quota = run_port_command( + self, + &request.context, + quota_spend_args( + &request.goal_id, + &request.agent_id, + &binding, + &request.turn_id, + ), + &observer, + ) + .await?; + require_payload_ok("a.payload, operation_id)?; + require_quota_spend_response("a.payload, operation_id)?; + + history = run_port_command( + self, + &request.context, + settlement_history_args(&request.goal_id), + &observer, + ) + .await?; + require_payload_ok(&history.payload, operation_id)?; + evidence = matching_durable_progress( + &history.payload, + &request.goal_id, + &request.agent_id, + &request.turn_id, + &request.settlement_token, + operation_id, + )?; + if evidence + .as_ref() + .is_some_and(|evidence| evidence.quota_spent) + { + let refreshed = run_port_command( + self, + &request.context, + inspect_goal_args( + &request.goal_id, + &request.agent_id, + Some(&request.turn_id), + ), + &observer, + ) + .await?; + snapshot = + project_goal_snapshot(&request.goal_id, &refreshed.payload, operation_id)?; + report_port_progress( + progress, + operation_id, + Some(request.context.task_id.clone()), + loopx_contract::LoopxCliProgressStage::SettlingTurn, + "LoopX quota accounting finalized and verified by readback", + ); + } + } + let Some(evidence) = evidence else { + report_port_progress( + progress, + operation_id, + Some(request.context.task_id.clone()), + loopx_contract::LoopxCliProgressStage::SettlingTurn, + "No matching durable LoopX writeback was found for this turn", + ); + 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() + }); + }; + if !evidence.quota_spent { + report_port_progress( + progress, + operation_id, + Some(request.context.task_id.clone()), + loopx_contract::LoopxCliProgressStage::SettlingTurn, + "Matching durable LoopX writeback exists, but quota finalization was not verified", + ); + return Ok(loopx_contract::LoopxCliSettleTurnResult { + goal_id: request.goal_id, + turn_id: request.turn_id, + status: loopx_contract::LoopxCliSettlementStatus::RetryRequired, + before_revision: request.expected_durable_revision, + after_revision: snapshot.durable_revision, + scheduler_hint_ms: snapshot.scheduler_hint_ms, + ..loopx_contract::LoopxCliSettleTurnResult::default() + }); + } + report_port_progress( + progress, + operation_id, + Some(request.context.task_id.clone()), + loopx_contract::LoopxCliProgressStage::SettlingTurn, + "Matched validated LoopX progress and quota settlement evidence", + ); + Ok(loopx_contract::LoopxCliSettleTurnResult { + goal_id: request.goal_id, + turn_id: request.turn_id, + receipt_id: evidence.effect_id, + status: if snapshot.state == loopx_contract::LoopxCliGoalState::Completed { + loopx_contract::LoopxCliSettlementStatus::GoalCompleted + } else { + loopx_contract::LoopxCliSettlementStatus::Settled + }, + 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 reset_goals<'a>( + &'a self, + request: loopx_contract::LoopxCliResetGoalsRequest, + progress: &'a dyn loopx_contract::LoopxCliProgressSink, + ) -> loopx_contract::LoopxCliFuture<'a, loopx_contract::LoopxCliResetGoalsResult> { + Box::pin(async move { + validate_operation_id(&request.call.operation_id)?; + let goal_ids = request + .goal_ids + .into_iter() + .map(|goal_id| goal_id.trim().to_string()) + .filter(|goal_id| !goal_id.is_empty()) + .collect::>() + .into_iter() + .collect::>(); + if goal_ids.is_empty() { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::InvalidInput, + &request.call.operation_id, + "reset_goals requires at least one explicit goal id", + false, + )); + } + let operation_id = &request.call.operation_id; + let observer = PortProcessObserver { + progress, + fallback: self.observer.as_ref(), + task_id: None, + stage: loopx_contract::LoopxCliProgressStage::Cancelling, + }; + report_port_progress( + progress, + operation_id, + None, + loopx_contract::LoopxCliProgressStage::Cancelling, + "Retiring global LoopX goal routes and archiving runtime state", + ); + let deadline = effective_deadline( + request.call.deadline_at, + self.config.command_deadline, + operation_id, + )?; + let mut result = loopx_contract::LoopxCliResetGoalsResult { + requested_goal_ids: goal_ids.clone(), + ..loopx_contract::LoopxCliResetGoalsResult::default() + }; + + for goal_id in goal_ids { + let retired = run_idempotent_global_command( + self, + operation_id, + retire_global_goal_args(&goal_id), + "goal_id not found in global registry:", + deadline, + &observer, + ) + .await?; + if let Some(output) = retired { + require_payload_ok(&output.payload, operation_id)?; + require_schema( + &output.payload, + "loopx_global_goal_retirement_v0", + operation_id, + )?; + let retired_ids = output + .payload + .get("retired_goal_ids") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if !retired_ids + .iter() + .any(|value| value.as_str() == Some(&goal_id)) + { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + format!("retire-global-goal did not confirm goal {goal_id}"), + false, + )); + } + result.retired_goal_ids.push(goal_id.clone()); + if let Some(path) = output.payload.get("backup_path").and_then(Value::as_str) { + result.backup_paths.push(path.to_string()); + } + } else { + result.already_absent_goal_ids.push(goal_id.clone()); + } + + let archived = run_idempotent_global_command( + self, + operation_id, + archive_runtime_args(&goal_id), + "runtime goal directory does not exist:", + deadline, + &observer, + ) + .await?; + if let Some(output) = archived { + require_payload_ok(&output.payload, operation_id)?; + if output.payload.get("archived").and_then(Value::as_bool) != Some(true) { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + format!("archive-runtime did not confirm goal {goal_id}"), + false, + )); + } + result.archived_goal_ids.push(goal_id.clone()); + if let Some(path) = output.payload.get("archive_path").and_then(Value::as_str) { + result.archive_paths.push(path.to_string()); + } + } else { + result.missing_runtime_goal_ids.push(goal_id); + } + } + Ok(result) + }) + } +} + +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 planned_settlement_token( + payload: &Value, + goal_id: &str, + agent_id: &str, + turn_id: &str, + operation_id: &str, +) -> loopx_contract::LoopxCliResult { + if let Some(binding) = planned_settlement_binding(payload) { + return Ok(binding.effect_id(goal_id, agent_id, turn_id)); + } + + payload + .pointer("/transaction/turn_key") + .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, + "turn plan did not contain a selected Todo or transaction.turn_key", + false, + ) + }) +} + +fn planned_settlement_binding(payload: &Value) -> Option { + let route_kind = payload.pointer("/route/kind").and_then(Value::as_str); + if route_kind == Some("replan_required") { + let obligation_id = payload + .pointer("/turn_envelope/replan_action_packet/obligation_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty())?; + return Some(SettlementBinding::AutonomousReplan { + obligation_id: obligation_id.to_string(), + }); + } + + planned_todo_id(payload).map(|todo_id| SettlementBinding::Todo { + todo_id: todo_id.to_string(), + }) +} + +fn planned_todo_id(payload: &Value) -> Option<&str> { + [ + "/route/selected_todo/todo_id", + "/turn_envelope/action/selected_todo/todo_id", + ] + .into_iter() + .find_map(|pointer| { + payload + .pointer(pointer) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + }) +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum SettlementBinding { + Todo { todo_id: String }, + AutonomousReplan { obligation_id: String }, +} + +impl SettlementBinding { + fn effect_id(&self, goal_id: &str, agent_id: &str, turn_id: &str) -> String { + match self { + Self::Todo { todo_id } => settlement_effect_id(goal_id, agent_id, todo_id, turn_id), + Self::AutonomousReplan { obligation_id } => { + replan_settlement_effect_id(goal_id, agent_id, obligation_id, turn_id) + } + } + } +} + +fn settlement_prompt_override( + cli_prefix: &str, + goal_id: &str, + agent_id: &str, + turn_id: &str, + binding: Option<&SettlementBinding>, +) -> String { + let binding_flags = match binding { + Some(SettlementBinding::Todo { todo_id }) => format!( + "--todo-id {todo_id} --turn-instance-id {turn_id} --agent-id {agent_id}" + ), + Some(SettlementBinding::AutonomousReplan { obligation_id }) => format!( + "--replan-obligation-id {obligation_id} --turn-instance-id {turn_id} --autonomous-replan-recorded --agent-id {agent_id}" + ), + None => format!("--turn-instance-id {turn_id} --agent-id {agent_id}"), + }; + format!( + "BitFun external-host settlement boundary (this final block overrides generic spend examples above):\n- The host owns quota accounting. Do not run `quota spend-slot`; do not retry or repair quota yourself.\n- Before ending a materially productive turn, validate the real result and append one accountable `refresh-state` using the exact CLI prefix and these mandatory binding flags: `{binding_flags}`.\n- Choose truthful classification, delivery scale, delivery outcome, and typed progress fields from the work actually completed; never invent progress.\n- On Windows, keep LoopX control-plane text arguments in English ASCII so command-line writeback remains lossless. Product files and user-visible source content are not restricted by this rule.\n- Required command shape: `{cli_prefix} refresh-state --goal-id {goal_id} {binding_flags}`.\n- After that durable writeback succeeds, end the turn. The host will verify it and append/read back the exact idempotent quota receipt." + ) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DurableSettlementEvidence { + effect_id: String, + binding: SettlementBinding, + quota_spent: bool, +} + +fn matching_durable_progress( + payload: &Value, + goal_id: &str, + agent_id: &str, + turn_id: &str, + settlement_token: &str, + operation_id: &str, +) -> loopx_contract::LoopxCliResult> { + let goals = payload + .get("goals") + .and_then(Value::as_array) + .ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + "LoopX history response did not contain goals", + false, + ) + })?; + let Some(goal) = goals + .iter() + .find(|goal| goal.get("id").and_then(Value::as_str) == Some(goal_id)) + else { + return Ok(None); + }; + let runs = goal + .get("latest_runs") + .and_then(Value::as_array) + .ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + "LoopX history goal did not contain latest_runs", + false, + ) + })?; + + let mut accountable_effects = BTreeMap::::new(); + let mut quota_effects = BTreeSet::::new(); + for run in runs { + let Some((effect_id, binding)) = matching_run_identity(run, goal_id, agent_id, turn_id) + else { + continue; + }; + match run.get("classification").and_then(Value::as_str) { + Some("quota_slot_spent") => { + quota_effects.insert(effect_id); + } + _ if run_has_accountable_progress(run) => { + accountable_effects.insert(effect_id, binding); + } + _ => {} + } + } + + // Every candidate above is already pinned to this exact goal, agent, and + // host-issued turn id by `matching_run_identity`. The planned settlement + // token additionally pins the todo the projection selected at build time, + // but the agent may legitimately advance a different todo within the same + // bounded turn; that progress is still durable evidence for this turn, so + // fall back to the turn-scoped candidates when no token match exists. + let token_matches = accountable_effects + .iter() + .filter(|(effect_id, _)| { + settlement_token == *effect_id || is_legacy_turn_key(settlement_token) + }) + .map(|(effect_id, binding)| (effect_id.clone(), binding.clone())) + .collect::>(); + let candidates = if token_matches.is_empty() { + accountable_effects + } else { + token_matches + }; + // Prefer a candidate whose quota spend already settled; otherwise take the + // first deterministic entry. + let chosen = candidates + .iter() + .find(|(effect_id, _)| quota_effects.contains(*effect_id)) + .or_else(|| candidates.iter().next()); + let Some((effect_id, binding)) = chosen else { + return Ok(None); + }; + Ok(Some(DurableSettlementEvidence { + effect_id: effect_id.clone(), + binding: binding.clone(), + quota_spent: quota_effects.contains(effect_id), + })) +} + +fn run_has_accountable_progress(run: &Value) -> bool { + if let Some(observation) = run + .get("progress_observation") + .filter(|observation| !observation.is_null()) + { + let typed_progress = observation.get("schema_version").and_then(Value::as_str) + == Some("typed_progress_observation_v0") + && matches!( + observation.get("result_class").and_then(Value::as_str), + Some("advanced" | "no_followup") + ); + if typed_progress { + return true; + } + } + + matches!( + run.get("delivery_outcome").and_then(Value::as_str), + Some("outcome_progress" | "primary_goal_outcome") + ) +} + +fn matching_run_identity( + run: &Value, + goal_id: &str, + agent_id: &str, + turn_id: &str, +) -> Option<(String, SettlementBinding)> { + let identity = run.get("settlement_identity")?; + let effect_id = identity + .get("effect_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty())? + .to_string(); + let exact_owner = identity.get("goal_id").and_then(Value::as_str) == Some(goal_id) + && identity.get("agent_id").and_then(Value::as_str) == Some(agent_id) + && identity.get("turn_instance_id").and_then(Value::as_str) == Some(turn_id) + && run.get("goal_id").and_then(Value::as_str) == Some(goal_id) + && run.get("agent_id").and_then(Value::as_str) == Some(agent_id) + && run.get("turn_instance_id").and_then(Value::as_str) == Some(turn_id); + if !exact_owner { + return None; + } + + let binding = match identity.get("schema_version").and_then(Value::as_str) { + Some("quota_settlement_identity_v0") => { + let todo_id = identity + .get("todo_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty())?; + if run.get("todo_id").and_then(Value::as_str) != Some(todo_id) { + return None; + } + SettlementBinding::Todo { + todo_id: todo_id.to_string(), + } + } + Some("quota_settlement_identity_v1") + if identity.get("binding_kind").and_then(Value::as_str) + == Some("autonomous_replan") => + { + let obligation_id = identity + .get("replan_obligation_id") + .or_else(|| identity.get("binding_id")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty())?; + if run.get("replan_obligation_id").and_then(Value::as_str) != Some(obligation_id) { + return None; + } + SettlementBinding::AutonomousReplan { + obligation_id: obligation_id.to_string(), + } + } + _ => return None, + }; + (effect_id == binding.effect_id(goal_id, agent_id, turn_id)).then_some((effect_id, binding)) +} + +fn settlement_effect_id(goal_id: &str, agent_id: &str, todo_id: &str, turn_id: &str) -> String { + format!("{goal_id}:{agent_id}:{todo_id}:{turn_id}") +} + +fn replan_settlement_effect_id( + goal_id: &str, + agent_id: &str, + obligation_id: &str, + turn_id: &str, +) -> String { + format!("{goal_id}:{agent_id}:autonomous_replan:{obligation_id}:{turn_id}") +} + +fn is_legacy_turn_key(value: &str) -> bool { + value.strip_prefix("sha256:").is_some_and(|digest| { + digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) +} + +fn project_legacy_settlement( + request: &loopx_contract::LoopxCliSettleTurnRequest, + snapshot: &loopx_contract::LoopxCliGoalSnapshot, + receipt: &Value, + operation_id: &str, +) -> loopx_contract::LoopxCliResult { + 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); + Ok(loopx_contract::LoopxCliSettleTurnResult { + goal_id: request.goal_id.clone(), + turn_id: request.turn_id.clone(), + receipt_id, + status: if !validation_succeeded { + loopx_contract::LoopxCliSettlementStatus::NoDurableProgress + } else if snapshot.state == loopx_contract::LoopxCliGoalState::Completed { + loopx_contract::LoopxCliSettlementStatus::GoalCompleted + } else { + loopx_contract::LoopxCliSettlementStatus::AlreadySettled + }, + before_revision: request.expected_durable_revision.clone(), + after_revision: snapshot.durable_revision.clone(), + validation_succeeded, + scheduler_hint_ms: snapshot.scheduler_hint_ms, + }) +} + +fn agent_shell_command(path: &Path) -> String { + let display = path.to_string_lossy().replace('"', "\\\""); + if cfg!(windows) { + format!("& \"{display}\"") + } else { + format!("'{}'", display.replace('\'', "'\\''")) + } +} + +fn agent_shell_value(value: &str) -> String { + if cfg!(windows) { + format!("\"{}\"", value.replace('"', "\\\"")) + } else { + format!("'{}'", value.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)) +} + +async fn run_idempotent_global_command( + adapter: &LoopxCliProcessAdapter, + operation_id: &str, + args: Vec, + missing_error_prefix: &str, + deadline: Duration, + observer: &dyn LoopxProcessObserver, +) -> loopx_contract::LoopxCliResult> { + match adapter + .run_global_json_command(operation_id, args, deadline, observer) + .await + { + Ok(output) => Ok(Some(output)), + Err(error) => { + if process_error_json(&error) + .and_then(|payload| { + payload + .get("error") + .and_then(Value::as_str) + .map(str::to_string) + }) + .is_some_and(|message| message.starts_with(missing_error_prefix)) + { + return Ok(None); + } + Err(map_port_error(error, operation_id)) + } + } +} + +fn process_error_json(error: &LoopxCliAdapterError) -> Option { + let LoopxCliAdapterError::Process(LoopxProcessError::Exited { stdout_tail, .. }) = error else { + return None; + }; + serde_json::from_str(&stdout_tail.join("\n")).ok() +} + +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 add_user_gate_args( + request: &loopx_contract::LoopxCliCreateUserGateRequest, +) -> loopx_contract::LoopxCliResult> { + let operation_id = &request.context.call.operation_id; + validate_nonempty("goal_id", &request.goal_id, operation_id)?; + validate_nonempty("agent_id", &request.agent_id, operation_id)?; + validate_nonempty("message", &request.message, operation_id)?; + if request.message.len() > 700 { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::InvalidInput, + operation_id, + "user gate message exceeds the 700-byte adapter limit", + false, + )); + } + if !is_public_token(&request.action_kind) { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::InvalidInput, + operation_id, + "user gate action_kind must be a bounded public-safe token", + false, + )); + } + Ok([ + "todo", + "add", + "--goal-id", + request.goal_id.as_str(), + "--role", + "user", + "--text", + request.message.as_str(), + "--task-class", + "user_gate", + "--action-kind", + request.action_kind.as_str(), + "--agent-id", + request.agent_id.as_str(), + "--blocks-agent", + request.agent_id.as_str(), + ] + .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 settlement_history_args(goal_id: &str) -> Vec { + [ + "history", + "--goal-id", + goal_id, + "--limit", + SETTLEMENT_HISTORY_LIMIT, + ] + .into_iter() + .map(OsString::from) + .collect() +} + +fn quota_spend_args( + goal_id: &str, + agent_id: &str, + binding: &SettlementBinding, + turn_id: &str, +) -> Vec { + let mut args = vec![ + "quota".to_string(), + "spend-slot".to_string(), + "--goal-id".to_string(), + goal_id.to_string(), + "--agent-id".to_string(), + agent_id.to_string(), + ]; + match binding { + SettlementBinding::Todo { todo_id } => { + args.extend(["--todo-id".to_string(), todo_id.clone()]); + } + SettlementBinding::AutonomousReplan { obligation_id } => { + args.extend(["--replan-obligation-id".to_string(), obligation_id.clone()]); + } + } + args.extend([ + "--turn-instance-id".to_string(), + turn_id.to_string(), + "--slots".to_string(), + "1".to_string(), + "--source".to_string(), + "heartbeat".to_string(), + "--execute".to_string(), + ]); + args.into_iter().map(OsString::from).collect() +} + +fn require_quota_spend_response( + payload: &Value, + operation_id: &str, +) -> loopx_contract::LoopxCliResult<()> { + if payload.get("mode").and_then(Value::as_str) != Some("spend-slot") + || payload.get("dry_run").and_then(Value::as_bool) != Some(false) + { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + "LoopX quota finalization did not return an executed spend-slot result", + false, + )); + } + Ok(()) +} + +fn retire_global_goal_args(goal_id: &str) -> Vec { + ["retire-global-goal", "--goal-id", goal_id, "--execute"] + .into_iter() + .map(OsString::from) + .collect() +} + +fn archive_runtime_args(goal_id: &str) -> Vec { + ["archive-runtime", "--goal-id", goal_id, "--execute"] + .into_iter() + .map(OsString::from) + .collect() +} + +#[cfg(test)] +mod prompt_contract_tests { + use super::{ + add_user_gate_args, loopx_contract, matching_durable_progress, planned_settlement_token, + project_goal_snapshot, quota_spend_args, settlement_prompt_override, SettlementBinding, + AGENT_CLI_USAGE_RULES, + }; + use serde_json::json; + + #[test] + fn agent_cli_contract_pins_global_format_and_scoped_agent_options() { + assert!(AGENT_CLI_USAGE_RULES.contains("before the subcommand")); + assert!(AGENT_CLI_USAGE_RULES.contains("whose `--help` output lists it")); + assert!(AGENT_CLI_USAGE_RULES.contains("do not repeat the rejected form")); + } + + #[test] + fn owner_review_gate_is_translated_to_a_scoped_typed_todo() { + let request = loopx_contract::LoopxCliCreateUserGateRequest { + context: loopx_contract::LoopxCliGoalContext { + call: loopx_contract::LoopxCliCallContext { + operation_id: "review-1".to_string(), + deadline_at: None, + }, + ..loopx_contract::LoopxCliGoalContext::default() + }, + goal_id: "goal-1".to_string(), + agent_id: "agent-1".to_string(), + message: "Approve another bounded segment or reject to stop.".to_string(), + action_kind: "autonomous_budget_review".to_string(), + }; + let args = add_user_gate_args(&request) + .expect("typed gate args") + .into_iter() + .map(|value| value.to_string_lossy().into_owned()) + .collect::>(); + + assert!(args + .windows(2) + .any(|pair| pair[0] == "--task-class" && pair[1] == "user_gate")); + assert!(args + .windows(2) + .any(|pair| pair[0] == "--blocks-agent" && pair[1] == "agent-1")); + assert!(args.windows(2).any(|pair| { + pair[0] == "--action-kind" && pair[1] == "autonomous_budget_review" + })); + } + + #[test] + fn selected_todo_builds_the_exact_settlement_effect_identity() { + let token = planned_settlement_token( + &json!({ + "route": {"selected_todo": {"todo_id": "todo-1"}}, + "transaction": {"turn_key": "sha256:legacy"} + }), + "goal-1", + "agent-1", + "turn-1", + "build-turn", + ) + .unwrap(); + + assert_eq!(token, "goal-1:agent-1:todo-1:turn-1"); + } + + #[test] + fn replan_route_builds_the_autonomous_replan_effect_identity() { + let token = planned_settlement_token( + &json!({ + "route": { + "kind": "replan_required", + "selected_todo": {"todo_id": "todo-1"} + }, + "turn_envelope": { + "replan_action_packet": {"obligation_id": "replan-1"} + }, + "transaction": {"turn_key": "sha256:legacy"} + }), + "goal-1", + "agent-1", + "turn-1", + "build-turn", + ) + .unwrap(); + + assert_eq!(token, "goal-1:agent-1:autonomous_replan:replan-1:turn-1"); + } + + #[test] + fn durable_settlement_requires_matching_advanced_progress_and_quota_spend() { + let effect_id = "goal-1:agent-1:todo-1:turn-1"; + let identity = json!({ + "schema_version": "quota_settlement_identity_v0", + "effect_id": effect_id, + "goal_id": "goal-1", + "agent_id": "agent-1", + "todo_id": "todo-1", + "turn_instance_id": "turn-1" + }); + let payload = json!({ + "goals": [{ + "id": "goal-1", + "latest_runs": [ + { + "goal_id": "goal-1", + "agent_id": "agent-1", + "todo_id": "todo-1", + "turn_instance_id": "turn-1", + "classification": "validated_progress", + "progress_observation": { + "schema_version": "typed_progress_observation_v0", + "result_class": "advanced", + "work_item_id": "todo-1" + }, + "settlement_identity": identity.clone() + }, + { + "goal_id": "goal-1", + "agent_id": "agent-1", + "todo_id": "todo-1", + "turn_instance_id": "turn-1", + "classification": "quota_slot_spent", + "settlement_identity": identity + } + ] + }] + }); + + let evidence = matching_durable_progress( + &payload, + "goal-1", + "agent-1", + "turn-1", + effect_id, + "settle-turn", + ) + .unwrap() + .expect("settlement evidence"); + assert_eq!(evidence.effect_id, effect_id); + assert!(evidence.quota_spent); + } + + #[test] + fn durable_progress_without_quota_exposes_the_binding_for_host_finalization() { + let effect_id = "goal-1:agent-1:todo-1:turn-1"; + let payload = json!({ + "goals": [{ + "id": "goal-1", + "latest_runs": [{ + "goal_id": "goal-1", + "agent_id": "agent-1", + "todo_id": "todo-1", + "turn_instance_id": "turn-1", + "classification": "validated_progress", + "progress_observation": { + "schema_version": "typed_progress_observation_v0", + "result_class": "advanced", + "work_item_id": "todo-1" + }, + "settlement_identity": { + "schema_version": "quota_settlement_identity_v0", + "effect_id": effect_id, + "goal_id": "goal-1", + "agent_id": "agent-1", + "todo_id": "todo-1", + "turn_instance_id": "turn-1" + } + }] + }] + }); + + let evidence = matching_durable_progress( + &payload, + "goal-1", + "agent-1", + "turn-1", + effect_id, + "settle-turn", + ) + .unwrap() + .expect("validated progress"); + assert!(!evidence.quota_spent); + assert_eq!( + evidence.binding, + SettlementBinding::Todo { + todo_id: "todo-1".to_string() + } + ); + } + + #[test] + fn autonomous_replan_progress_uses_the_v1_settlement_binding() { + let effect_id = "goal-1:agent-1:autonomous_replan:replan-1:turn-1"; + let payload = json!({ + "goals": [{ + "id": "goal-1", + "latest_runs": [{ + "goal_id": "goal-1", + "agent_id": "agent-1", + "turn_instance_id": "turn-1", + "replan_obligation_id": "replan-1", + "classification": "state_projection_repair", + "delivery_outcome": "outcome_progress", + "progress_observation": { + "schema_version": "typed_progress_observation_v0", + "result_class": "advanced" + }, + "settlement_identity": { + "schema_version": "quota_settlement_identity_v1", + "effect_id": effect_id, + "goal_id": "goal-1", + "agent_id": "agent-1", + "turn_instance_id": "turn-1", + "binding_kind": "autonomous_replan", + "binding_id": "replan-1", + "replan_obligation_id": "replan-1" + } + }] + }] + }); + + let evidence = matching_durable_progress( + &payload, + "goal-1", + "agent-1", + "turn-1", + "sha256:legacy", + "settle-turn", + ) + .unwrap() + .expect("replan progress"); + assert_eq!(evidence.effect_id, effect_id); + assert_eq!( + evidence.binding, + SettlementBinding::AutonomousReplan { + obligation_id: "replan-1".to_string() + } + ); + assert!(!evidence.quota_spent); + } + + #[test] + fn quota_finalization_uses_the_exact_typed_binding() { + let args = quota_spend_args( + "goal-1", + "agent-1", + &SettlementBinding::AutonomousReplan { + obligation_id: "replan-1".to_string(), + }, + "turn-1", + ); + let args = args + .iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + + assert!(args + .windows(2) + .any(|pair| pair == ["--replan-obligation-id", "replan-1"])); + assert!(args + .windows(2) + .any(|pair| pair == ["--turn-instance-id", "turn-1"])); + assert!(args + .windows(2) + .any(|pair| pair == ["--source", "heartbeat"])); + assert!(!args.iter().any(|arg| arg == "--todo-id")); + assert!(args.iter().any(|arg| arg == "--execute")); + } + + #[test] + fn prompt_override_keeps_writeback_exact_and_spend_host_owned() { + let prompt = settlement_prompt_override( + "loopx-prefix", + "goal-1", + "agent-1", + "turn-1", + Some(&SettlementBinding::Todo { + todo_id: "todo-1".to_string(), + }), + ); + + assert!(prompt.contains("--todo-id todo-1 --turn-instance-id turn-1")); + assert!(prompt.contains("The host owns quota accounting")); + assert!(prompt.contains("Do not run `quota spend-slot`")); + assert!(prompt.contains("English ASCII")); + } + + #[test] + fn terminal_no_followup_is_a_completed_goal() { + let snapshot = project_goal_snapshot( + "goal-1", + &json!({ + "ok": true, + "schema_version": "loopx_turn_plan_v0", + "turn_envelope": { + "should_run": false, + "state": "terminal_no_followup", + "effective_action": "terminal_no_followup", + "action_signature": { + "source_hash": "sha256:terminal" + } + } + }), + "inspect-goal", + ) + .unwrap(); + + assert_eq!( + snapshot.run_decision, + loopx_contract::LoopxCliRunDecision::Complete + ); + assert_eq!(snapshot.state, loopx_contract::LoopxCliGoalState::Completed); + } + + #[test] + fn durable_progress_on_an_unplanned_todo_still_settles_the_turn() { + let planned_token = "goal-1:agent-1:todo-planned:turn-1"; + let actual_effect = "goal-1:agent-1:todo-actual:turn-1"; + let identity = json!({ + "schema_version": "quota_settlement_identity_v0", + "effect_id": actual_effect, + "goal_id": "goal-1", + "agent_id": "agent-1", + "todo_id": "todo-actual", + "turn_instance_id": "turn-1" + }); + let payload = json!({ + "goals": [{ + "id": "goal-1", + "latest_runs": [ + { + "goal_id": "goal-1", + "agent_id": "agent-1", + "todo_id": "todo-actual", + "turn_instance_id": "turn-1", + "classification": "validated_progress", + "progress_observation": { + "schema_version": "typed_progress_observation_v0", + "result_class": "advanced", + "work_item_id": "todo-actual" + }, + "settlement_identity": identity.clone() + }, + { + "goal_id": "goal-1", + "agent_id": "agent-1", + "todo_id": "todo-actual", + "turn_instance_id": "turn-1", + "classification": "quota_slot_spent", + "settlement_identity": identity + } + ] + }] + }); + + let evidence = matching_durable_progress( + &payload, + "goal-1", + "agent-1", + "turn-1", + planned_token, + "settle-turn", + ) + .unwrap() + .expect("turn-scoped evidence on a different todo"); + assert!(evidence.quota_spent); + } + + #[test] + fn legacy_validated_progress_uses_accountable_delivery_outcome() { + let effect_id = "goal-1:agent-1:todo-1:turn-1"; + let identity = json!({ + "schema_version": "quota_settlement_identity_v0", + "effect_id": effect_id, + "goal_id": "goal-1", + "agent_id": "agent-1", + "todo_id": "todo-1", + "turn_instance_id": "turn-1" + }); + let payload = json!({ + "goals": [{ + "id": "goal-1", + "latest_runs": [ + { + "goal_id": "goal-1", + "agent_id": "agent-1", + "todo_id": "todo-1", + "turn_instance_id": "turn-1", + "classification": "validated_progress", + "delivery_outcome": "outcome_progress", + "settlement_identity": identity.clone() + }, + { + "goal_id": "goal-1", + "agent_id": "agent-1", + "todo_id": "todo-1", + "turn_instance_id": "turn-1", + "classification": "quota_slot_spent", + "settlement_identity": identity + } + ] + }] + }); + + let evidence = matching_durable_progress( + &payload, + "goal-1", + "agent-1", + "turn-1", + effect_id, + "settle-turn", + ) + .unwrap() + .expect("legacy settlement evidence"); + assert!(evidence.quota_spent); + } +} + +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 control_status = payload + .get("status") + .and_then(Value::as_str) + .unwrap_or_default(); + let operator_gate_notify = matches!( + effective_action, + "operator_gate" | "operator_gate_notify" | "waiting_for_user" + ) || matches!(state_text, "operator_gate" | "operator_gate_notify") + || control_status == "operator_gate_notify"; + let run_decision = if user_action_required || operator_gate_notify { + loopx_contract::LoopxCliRunDecision::WaitingForUser + } else if should_run { + loopx_contract::LoopxCliRunDecision::RunNow + } else if effective_action == "terminal_no_followup" + || matches!( + state_text, + "completed" | "complete" | "closed" | "terminal_no_followup" + ) + { + 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..8ed9239a88 --- /dev/null +++ b/src/crates/services/services-integrations/src/miniapp/loopx_workspace.rs @@ -0,0 +1,1554 @@ +//! 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(), + // A first-time bare clone downloads the full history of the target + // repository; medium repositories on residential bandwidth + // regularly need more than five minutes. 30 minutes bounds runaway + // transfers without killing an otherwise healthy clone. + clone_deadline: Duration::from_secs(1800), + 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, + } +} + +/// Cheap validity probe for the shared bare repository. A clone that was +/// killed mid-transfer (deadline, crash) leaves a directory with no resolvable +/// HEAD; every later `worktree add` would fail with "invalid reference". +pub fn plan_git_bare_head_check_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("rev-parse"), + OsString::from("--verify"), + OsString::from("HEAD"), + ], + 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. + 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 shared bare repository: {error}"), + true, + ) + })?; + // A bare repository abandoned by an interrupted clone has no resolvable + // HEAD and would poison every task of this repository with + // "fatal: invalid reference: HEAD" at worktree add. Rebuild it from + // scratch, but only while no sibling task worktree still links into it: + // removing a shared object database out from under live worktrees would + // silently break them. + let bare_usable = if bare_exists { + let head_check = self + .run_git( + &request.operation_id, + plan_git_bare_head_check_command(&self.config.git_executable, &layout), + self.config.command_deadline, + cancellation.clone(), + ) + .await; + match head_check { + Ok(_) => true, + Err(_) => { + let linked_worktrees = self + .run_git( + &request.operation_id, + plan_git_worktree_list_command(&self.config.git_executable, &layout), + self.config.command_deadline, + cancellation.clone(), + ) + .await + .map(|listing| { + listing + .stdout + .lines() + .filter(|line| line.starts_with("worktree ")) + .count() + // The first porcelain entry is the bare + // repository itself. + .saturating_sub(1) + }) + // An unlistable bare repository cannot serve any + // worktree either; treat it as abandoned. + .unwrap_or(0); + if linked_worktrees > 0 { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::Conflict, + &request.operation_id, + format!( + "shared bare repository {} has no valid HEAD (an earlier clone was interrupted) but still hosts {linked_worktrees} task worktree(s); archive those tasks so it can be rebuilt", + layout.bare_repo_path.display() + ), + false, + )); + } + 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 corrupt shared bare repository left by an interrupted clone: {error}" + ), + true, + ) + })?; + false + } + } + } else { + false + }; + if !bare_usable { + 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, + )); + } + let parent = canonical_root + .parent() + .expect("validated workspace root parent"); + let detached_root = parent.join(format!(".{root_name}.reset-{}", uuid::Uuid::new_v4())); + tokio::fs::rename(&canonical_root, &detached_root) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + format!("failed to detach LoopX workspace root for cleanup: {error}"), + true, + ) + })?; + if let Err(error) = tokio::fs::create_dir_all(&canonical_root).await { + let _ = tokio::fs::rename(&detached_root, &canonical_root).await; + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + format!("failed to recreate LoopX workspace root: {error}"), + true, + )); + } + + let retained = self + .restore_bare_repository_caches(&detached_root, &canonical_root, &request.operation_id) + .await; + if let Err(error) = &retained { + log::warn!("LoopX repository cache retention failed during reset: {error}"); + } + let prune_paths = retained.unwrap_or_default(); + for bare_repo_path in prune_paths { + if let Err(error) = self + .run_git( + &request.operation_id, + git_worktree_prune_plan(&self.config.git_executable, &bare_repo_path), + self.config.command_deadline, + CancellationToken::new(), + ) + .await + { + log::warn!( + "Failed to prune retained LoopX worktree registrations: bare_repo={}, error={}", + bare_repo_path.display(), + error + ); + } + } + + tokio::spawn(async move { + match tokio::fs::remove_dir_all(&detached_root).await { + Ok(()) => log::info!( + "Detached LoopX task workspaces were reclaimed in the background: path={}", + detached_root.display() + ), + Err(error) => log::warn!( + "Failed to reclaim detached LoopX task workspaces: path={}, error={}", + detached_root.display(), + error + ), + } + }); + Ok(loopx_contract::LoopxWorkspaceResetResult { removed: true }) + } + + async fn restore_bare_repository_caches( + &self, + detached_root: &Path, + fresh_root: &Path, + operation_id: &str, + ) -> loopx_contract::LoopxHostResult> { + let mut retained = Vec::new(); + let mut repositories = tokio::fs::read_dir(detached_root).await.map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + operation_id, + format!("failed to enumerate detached LoopX repositories: {error}"), + true, + ) + })?; + while let Some(entry) = repositories.next_entry().await.map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + operation_id, + format!("failed to inspect detached LoopX repository: {error}"), + true, + ) + })? { + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if !is_repository_hash(name) + || !entry + .file_type() + .await + .map(|kind| kind.is_dir()) + .unwrap_or(false) + { + continue; + } + let source = entry.path().join("bare.git"); + let Ok(metadata) = tokio::fs::symlink_metadata(&source).await else { + continue; + }; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + continue; + } + let destination_dir = fresh_root.join(name); + tokio::fs::create_dir_all(&destination_dir) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + operation_id, + format!("failed to recreate LoopX repository cache directory: {error}"), + true, + ) + })?; + let destination = destination_dir.join("bare.git"); + tokio::fs::rename(&source, &destination) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + operation_id, + format!("failed to retain LoopX bare repository cache: {error}"), + true, + ) + })?; + retained.push(destination); + } + Ok(retained) + } +} + +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_worktree_prune_plan(git: &Path, bare_repo_path: &Path) -> LoopxGitCommandPlan { + LoopxGitCommandPlan { + executable: git.to_path_buf(), + args: vec![ + OsString::from("--git-dir"), + bare_repo_path.as_os_str().to_owned(), + OsString::from("worktree"), + OsString::from("prune"), + OsString::from("--expire"), + OsString::from("now"), + ], + environment: git_noninteractive_environment(), + current_dir: None, + } +} + +fn is_repository_hash(value: &str) -> bool { + value.len() == 20 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +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/src/web_tools.rs b/src/crates/services/services-integrations/src/web_tools.rs index 971b513425..d3dec0765e 100644 --- a/src/crates/services/services-integrations/src/web_tools.rs +++ b/src/crates/services/services-integrations/src/web_tools.rs @@ -5,7 +5,10 @@ use serde_json::json; use std::time::Duration; use thiserror::Error; -const USER_AGENT_VALUE: &str = "BitFun/1.0"; +const USER_AGENT_VALUE: &str = "BitFun/1.0 (+https://openbitfun.com)"; +const WEB_FETCH_ACCEPT_VALUE: &str = + "text/html,application/xhtml+xml,application/json;q=0.9,text/plain;q=0.8,*/*;q=0.5"; +const WEB_FETCH_ACCEPT_LANGUAGE_VALUE: &str = "en-US,en;q=0.9"; const WEB_FETCH_TIMEOUT_SECS: u64 = 30; const EXA_URL: &str = "https://mcp.exa.ai/mcp"; const EXA_TIMEOUT_SECS: u64 = 25; @@ -17,7 +20,14 @@ pub enum WebToolNetworkError { #[error("Failed to fetch URL: {0}")] Fetch(String), #[error("HTTP error {status}: {reason}")] - HttpStatus { status: String, reason: String }, + HttpStatus { + status_code: u16, + status: String, + reason: String, + retry_after: Option, + rate_limit_remaining: Option, + rate_limit_reset: Option, + }, #[error("Failed to read response: {0}")] ReadResponse(String), #[error("Failed to send request: {0}")] @@ -72,18 +82,27 @@ impl WebToolNetworkProvider { let response = client .get(url) + .header(reqwest::header::ACCEPT, WEB_FETCH_ACCEPT_VALUE) + .header( + reqwest::header::ACCEPT_LANGUAGE, + WEB_FETCH_ACCEPT_LANGUAGE_VALUE, + ) .send() .await .map_err(|error| WebToolNetworkError::Fetch(error.to_string()))?; if !response.status().is_success() { + let status = response.status(); return Err(WebToolNetworkError::HttpStatus { - status: response.status().to_string(), - reason: response - .status() + status_code: status.as_u16(), + status: status.to_string(), + reason: status .canonical_reason() .unwrap_or("Unknown error") .to_string(), + retry_after: response_header(&response, reqwest::header::RETRY_AFTER), + rate_limit_remaining: response_header_name(&response, "x-ratelimit-remaining"), + rate_limit_reset: response_header_name(&response, "x-ratelimit-reset"), }); } @@ -156,6 +175,25 @@ impl WebToolNetworkProvider { } } +fn response_header( + response: &reqwest::Response, + name: reqwest::header::HeaderName, +) -> Option { + response + .headers() + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_string) +} + +fn response_header_name(response: &reqwest::Response, name: &str) -> Option { + response + .headers() + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_string) +} + fn parse_exa_sse(text: &str) -> Result { let out = text .lines() 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..7c5cbc8cce --- /dev/null +++ b/src/crates/services/services-integrations/tests/miniapp_loopx_contracts.rs @@ -0,0 +1,1233 @@ +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, LoopxWorkspaceResetRequest, +}; +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, + "status": "operator_gate_notify", + "schema_version": "loopx_turn_plan_v0", + "turn_envelope": { + "should_run": true, + "state": "active", + "effective_action": "operator_gate_notify", + "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_reset_detaches_tasks_but_retains_bare_repository_cache() { + 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-reset-prepare".to_string(), + task_id: "task-42".to_string(), + item, + }) + .await + .unwrap(); + let worktree = PathBuf::from(prepared.worktree_path); + let repository_dir = worktree.parent().unwrap().to_path_buf(); + let bare = repository_dir.join("bare.git"); + std::fs::write(worktree.join("large-generated-file"), b"payload").unwrap(); + + let reset = service + .reset(LoopxWorkspaceResetRequest { + operation_id: "workspace-reset".to_string(), + }) + .await + .unwrap(); + + assert!(reset.removed); + assert!(root.exists()); + assert!(bare.exists()); + assert!(!worktree.exists()); + assert!(runner.plans.lock().unwrap().iter().any(|plan| { + plan.args + .windows(2) + .any(|pair| pair == ["worktree", "prune"]) + })); +} + +#[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..b2f9fc4bb5 --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/hooks/loopxBridgeProtocol.ts @@ -0,0 +1,313 @@ +import type { + LoopxActionKind, + LoopxActionRequest, + LoopxAttachRequest, + LoopxCreateTaskRequest, + LoopxEventsSinceRequest, + LoopxIssueKey, + LoopxItemKind, + LoopxPermissionScope, + LoopxResolveIntakeRequest, + LoopxTurnOutputSinceRequest, +} from '@/infrastructure/api/service-api/LoopxAPI'; + +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', 'resumeDetected'], 'params'); + return { + kind: 'attach', + request: { + knownStreamId: optionalString(rawParams.knownStreamId, 'params.knownStreamId'), + afterCursor: optionalUnsignedInteger(rawParams.afterCursor, 'params.afterCursor'), + resumeDetected: rawParams.resumeDetected === undefined + ? undefined + : requiredBoolean(rawParams.resumeDetected, 'params.resumeDetected'), + }, + }; + } + + 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..8321c94e2e 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,18 @@ 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(), + loopxListModels: 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 +39,19 @@ vi.mock('@/infrastructure/api/service-api/MiniAppAPI', () => ({ miniAppAPI: { agentEnsureSession: mocks.agentEnsureSession, agentRun: mocks.agentRun, + getCustomizationMetadata: mocks.getCustomizationMetadata, + }, +})); + +vi.mock('@/infrastructure/api/service-api/LoopxAPI', () => ({ + loopxAPI: { + attach: mocks.loopxAttach, + listModels: mocks.loopxListModels, + resolveIntake: mocks.loopxResolveIntake, + createTask: mocks.loopxCreateTask, + action: mocks.loopxAction, + eventsSince: mocks.loopxEventsSince, + turnOutputSince: mocks.loopxTurnOutputSince, }, })); @@ -39,7 +62,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 +145,23 @@ function BridgeHarness() { return