diff --git a/frontend/.env.example b/frontend/.env.example index 9a373a548..05610637a 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -3,3 +3,4 @@ VITE_GOOGLE_CLIENT_ID="" # google oauth client id VITE_DISABLE_BUILD_OPTIMIZATIONS=false # set true to disable minify/treeshake VITE_THEME="" # set to "sage" for sage theme, leave empty for default VITE_PORT=1420 # frontend dev server port +VITE_FRONTEND_URL=http://localhost:1420 # frontend url for desktop app \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index cbb3d71a3..3341506da 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -49,6 +49,7 @@ "@tailwindcss/vite": "^4.1.5", "@tanstack/react-table": "^8.21.3", "@tauri-apps/api": "^2.5.0", + "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-process": "^2.2.1", "@tauri-apps/plugin-shell": "^2.2.1", "@types/hast": "^3.0.4", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 0bf002b7f..c56a9b5bb 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: '@tauri-apps/api': specifier: ^2.5.0 version: 2.7.0 + '@tauri-apps/plugin-dialog': + specifier: ^2.6.0 + version: 2.7.0 '@tauri-apps/plugin-process': specifier: ^2.2.1 version: 2.3.0 @@ -1678,6 +1681,9 @@ packages: resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} + '@tauri-apps/api@2.10.1': + resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} + '@tauri-apps/api@2.7.0': resolution: {integrity: sha512-v7fVE8jqBl8xJFOcBafDzXFc8FnicoH3j8o8DNNs0tHuEBmXUDqrCOAzMRX0UkfpwqZLqvrvK0GNQ45DfnoVDg==} @@ -1752,6 +1758,9 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-dialog@2.7.0': + resolution: {integrity: sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw==} + '@tauri-apps/plugin-process@2.3.0': resolution: {integrity: sha512-0DNj6u+9csODiV4seSxxRbnLpeGYdojlcctCuLOCgpH9X3+ckVZIEj6H7tRQ7zqWr7kSTEWnrxtAdBb0FbtrmQ==} @@ -6060,6 +6069,8 @@ snapshots: '@tanstack/table-core@8.21.3': {} + '@tauri-apps/api@2.10.1': {} + '@tauri-apps/api@2.7.0': {} '@tauri-apps/cli-darwin-arm64@2.7.1': @@ -6109,6 +6120,10 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.7.1 '@tauri-apps/cli-win32-x64-msvc': 2.7.1 + '@tauri-apps/plugin-dialog@2.7.0': + dependencies: + '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-process@2.3.0': dependencies: '@tauri-apps/api': 2.7.0 diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index ab859b485..5d83a4cb2 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -6,12 +6,26 @@ version = 4 name = "II-Agent" version = "0.1.0" dependencies = [ + "chrono", + "futures-util", + "glob", + "lopdf", + "regex", + "reqwest 0.12.28", + "rust_socketio", "serde", "serde_json", + "sha2", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-process", "tauri-plugin-shell", + "tokio", + "wasmtime", + "wasmtime-wasi", + "wat", + "zstd", ] [[package]] @@ -29,6 +43,35 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -53,6 +96,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -74,6 +123,54 @@ version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object 0.37.3", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "atk" version = "0.18.2" @@ -97,12 +194,29 @@ dependencies = [ "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.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +[[package]] +name = "backoff" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" +dependencies = [ + "getrandom 0.2.16", + "instant", + "rand 0.8.5", +] + [[package]] name = "backtrace" version = "0.3.74" @@ -113,7 +227,7 @@ dependencies = [ "cfg-if", "libc", "miniz_oxide", - "object", + "object 0.36.7", "rustc-demangle", "windows-targets 0.52.6", ] @@ -138,11 +252,11 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -154,6 +268,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -199,6 +322,12 @@ version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.23.0" @@ -226,7 +355,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.11.0", "cairo-sys-rs", "glib", "libc", @@ -254,6 +383,84 @@ dependencies = [ "serde", ] +[[package]] +name = "cap-fs-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5528f85b1e134ae811704e41ef80930f56e795923f866813255bc342cc20654" +dependencies = [ + "cap-primitives", + "cap-std", + "io-lifetimes", + "windows-sys 0.59.0", +] + +[[package]] +name = "cap-net-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20a158160765c6a7d0d8c072a53d772e4cb243f38b04bfcf6b4939cfbe7482e7" +dependencies = [ + "cap-primitives", + "cap-std", + "rustix 1.1.2", + "smallvec", +] + +[[package]] +name = "cap-primitives" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes", + "ipnet", + "maybe-owned", + "rustix 1.1.2", + "rustix-linux-procfs", + "windows-sys 0.59.0", + "winx", +] + +[[package]] +name = "cap-rand" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8144c22e24bbcf26ade86cb6501a0916c46b7e4787abdb0045a467eb1645a1d" +dependencies = [ + "ambient-authority", + "rand 0.8.5", +] + +[[package]] +name = "cap-std" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes", + "rustix 1.1.2", +] + +[[package]] +name = "cap-time-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def102506ce40c11710a9b16e614af0cde8e76ae51b1f48c04b8d79f4b671a80" +dependencies = [ + "ambient-authority", + "cap-primitives", + "iana-time-zone", + "once_cell", + "rustix 1.1.2", + "winx", +] + [[package]] name = "cargo-platform" version = "0.1.9" @@ -284,15 +491,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02260d489095346e5cafd04dea8e8cb54d1d74fcd759022a9b72986ebe9a1257" dependencies = [ "serde", - "toml", + "toml 0.8.22", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", ] [[package]] name = "cc" -version = "1.2.21" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8691782945451c1c383942c4874dbe63814f61cb57ef773cda2972682b7bb3c0" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -343,9 +562,30 @@ checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", "serde", - "windows-link", + "wasm-bindgen", + "windows-link 0.1.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 = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.12", ] [[package]] @@ -374,6 +614,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.0" @@ -396,10 +646,23 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ - "bitflags 2.9.0", - "core-foundation", + "bitflags 2.11.0", + "core-foundation 0.10.0", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.0", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -409,8 +672,8 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.9.0", - "core-foundation", + "bitflags 2.11.0", + "core-foundation 0.10.0", "libc", ] @@ -423,6 +686,112 @@ dependencies = [ "libc", ] +[[package]] +name = "cranelift-bforest" +version = "0.113.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "540b193ff98b825a1f250a75b3118911af918a734154c69d80bcfcf91e7e9522" +dependencies = [ + "cranelift-entity", +] + +[[package]] +name = "cranelift-bitset" +version = "0.113.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7cb269598b9557ab942d687d3c1086d77c4b50dcf35813f3a65ba306fd42279" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-codegen" +version = "0.113.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46566d7c83a8bff4150748d66020f4c7224091952aa4b4df1ec4959c39d937a1" +dependencies = [ + "bumpalo", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.14.5", + "log", + "regalloc2", + "rustc-hash", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.113.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2df8a86a34236cc75a8a6a271973da779c2aeb36c43b6e14da474cf931317082" +dependencies = [ + "cranelift-codegen-shared", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.113.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf75340b6a57b7c7c1b74f10d3d90883ee6d43a554be8131a4046c2ebcf5eb65" + +[[package]] +name = "cranelift-control" +version = "0.113.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e84495bc5d23d86aad8c86f8ade4af765b94882af60d60e271d3153942f1978" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.113.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "963c17147b80df351965e57c04d20dbedc85bcaf44c3436780a59a3f1ff1b1c2" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-frontend" +version = "0.113.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727f02acbc4b4cb2ba38a6637101d579db50190df1dd05168c68e762851a3dd5" +dependencies = [ + "cranelift-codegen", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.113.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b00cc2e03c748f2531eea01c871f502b909d30295fdcad43aec7bf5c5b4667" + +[[package]] +name = "cranelift-native" +version = "0.113.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbeaf978dc7c1a2de8bbb9162510ed218eb156697bc45590b8fbdd69bb08e8de" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + [[package]] name = "crc32fast" version = "1.4.2" @@ -529,6 +898,12 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "deranged" version = "0.4.0" @@ -562,32 +937,46 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", ] [[package]] name = "dirs-sys" -version = "0.5.0" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" dependencies = [ "libc", - "option-ext", - "redox_users", - "windows-sys 0.59.0", + "redox_users 0.4.6", + "winapi", ] [[package]] -name = "dispatch" -version = "0.2.0" +name = "dirs-sys" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.0", + "windows-sys 0.61.2", +] [[package]] name = "dispatch2" @@ -595,7 +984,9 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.11.0", + "block2 0.6.1", + "libc", "objc2 0.6.1", ] @@ -612,9 +1003,9 @@ dependencies = [ [[package]] name = "dlopen2" -version = "0.7.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1297103d2bbaea85724fcee6294c2d50b1081f9ad47d0f6f6f61eda65315a6" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" dependencies = [ "dlopen2_derive", "libc", @@ -670,15 +1061,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" [[package]] -name = "embed-resource" -version = "3.0.2" +name = "ecb" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbc6e0d8e0c03a655b53ca813f0463d2c956bc4db8138dbc89f120b066551e3" +checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7" dependencies = [ - "cc", - "memchr", + "cipher", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "embed-resource" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbc6e0d8e0c03a655b53ca813f0463d2c956bc4db8138dbc89f120b066551e3" +dependencies = [ + "cc", + "memchr", "rustc_version", - "toml", + "toml 0.8.22", "vswhom", "winreg", ] @@ -689,6 +1095,18 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -714,6 +1132,39 @@ dependencies = [ "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 = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.1.2", + "windows-sys 0.59.0", +] + [[package]] name = "fdeflate" version = "0.3.7" @@ -733,6 +1184,12 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "flate2" version = "1.1.1" @@ -749,6 +1206,21 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "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" @@ -756,7 +1228,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -770,6 +1242,12 @@ dependencies = [ "syn 2.0.101", ] +[[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" @@ -785,6 +1263,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes", + "rustix 1.1.2", + "windows-sys 0.59.0", +] + [[package]] name = "futf" version = "0.1.5" @@ -795,6 +1284,20 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -802,6 +1305,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1003,8 +1507,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1014,9 +1520,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasi 0.14.2+wasi-0.2.4", + "wasm-bindgen", ] [[package]] @@ -1024,6 +1532,11 @@ name = "gimli" version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +dependencies = [ + "fallible-iterator", + "indexmap 2.13.0", + "stable_deref_trait", +] [[package]] name = "gio" @@ -1063,7 +1576,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.11.0", "futures-channel", "futures-core", "futures-executor", @@ -1173,6 +1686,25 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1181,9 +1713,28 @@ checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] name = "hashbrown" -version = "0.15.3" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "serde", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "heck" @@ -1257,41 +1808,82 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hyper" -version = "1.6.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", + "futures-core", + "h2", "http", "http-body", "httparse", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "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.11" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497bbc33a26fdd4af9ed9c70d63f61cf56a938375fbb32df34db9b1cd6d643f2" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64 0.22.1", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1320,9 +1912,9 @@ dependencies = [ [[package]] name = "ico" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", "png", @@ -1446,6 +2038,12 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -1486,13 +2084,14 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.9.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown 0.15.3", + "hashbrown 0.16.1", "serde", + "serde_core", ] [[package]] @@ -1504,12 +2103,57 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "io-extras" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" +dependencies = [ + "io-lifetimes", + "windows-sys 0.59.0", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + [[package]] name = "ipnet" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is-docker" version = "0.2.0" @@ -1529,6 +2173,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -1580,12 +2233,24 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.2", + "libc", +] + [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "797146bb2677299a1eb6b7b50a890f4c361b29ef967addf5b2fa45dae1bb6d7d" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -1618,7 +2283,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.11.0", "serde", "unicode-segmentation", ] @@ -1631,15 +2296,21 @@ checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" dependencies = [ "cssparser", "html5ever", - "indexmap 2.9.0", + "indexmap 2.13.0", "selectors", ] [[package]] -name = "lazy_static" -version = "1.5.0" +name = "leb128" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" + +[[package]] +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libappindicator" @@ -1681,16 +2352,34 @@ dependencies = [ "winapi", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.11.0", "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "litemap" version = "0.7.5" @@ -1713,12 +2402,53 @@ version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +[[package]] +name = "lopdf" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fa2559e99ba0f26a12458aabc754432c805bbb8cba516c427825a997af1fb7" +dependencies = [ + "aes", + "bitflags 2.11.0", + "cbc", + "ecb", + "encoding_rs", + "flate2", + "indexmap 2.13.0", + "itoa", + "log", + "md-5", + "nom", + "nom_locate", + "rand 0.9.2", + "rangemap", + "sha2", + "stringprep", + "thiserror 2.0.12", + "weezl", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "markup5ever" version = "0.14.1" @@ -1750,12 +2480,37 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix 1.1.2", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -1814,15 +2569,32 @@ dependencies = [ ] [[package]] -name = "ndk" -version = "0.9.0" +name = "native-tls" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ - "bitflags 2.9.0", - "jni-sys", + "libc", "log", - "ndk-sys", + "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.11.0", + "jni-sys", + "log", + "ndk-sys", "num_enum", "raw-window-handle", "thiserror 1.0.69", @@ -1855,6 +2627,26 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom_locate" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" +dependencies = [ + "bytecount", + "memchr", + "nom", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -1923,38 +2715,10 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.11.0", "block2 0.6.1", - "libc", "objc2 0.6.1", - "objc2-cloud-kit", - "objc2-core-data", "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-image", - "objc2-foundation 0.3.1", - "objc2-quartz-core 0.3.1", -] - -[[package]] -name = "objc2-cloud-kit" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17614fdcd9b411e6ff1117dfb1d0150f908ba83a7df81b1f118005fe0a8ea15d" -dependencies = [ - "bitflags 2.9.0", - "objc2 0.6.1", - "objc2-foundation 0.3.1", -] - -[[package]] -name = "objc2-core-data" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291fbbf7d29287518e8686417cf7239c74700fd4b607623140a7d4a3c834329d" -dependencies = [ - "bitflags 2.9.0", - "objc2 0.6.1", "objc2-foundation 0.3.1", ] @@ -1964,7 +2728,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.11.0", "dispatch2", "objc2 0.6.1", ] @@ -1975,21 +2739,8 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4" dependencies = [ - "bitflags 2.9.0", - "dispatch2", - "objc2 0.6.1", + "bitflags 2.11.0", "objc2-core-foundation", - "objc2-io-surface", -] - -[[package]] -name = "objc2-core-image" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79b3dc0cc4386b6ccf21c157591b34a7f44c8e75b064f85502901ab2188c007e" -dependencies = [ - "objc2 0.6.1", - "objc2-foundation 0.3.1", ] [[package]] @@ -2013,7 +2764,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.11.0", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -2025,20 +2776,8 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.11.0", "block2 0.6.1", - "libc", - "objc2 0.6.1", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-io-surface" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c" -dependencies = [ - "bitflags 2.9.0", "objc2 0.6.1", "objc2-core-foundation", ] @@ -2049,7 +2788,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.11.0", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -2061,31 +2800,20 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.11.0", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-metal", ] -[[package]] -name = "objc2-quartz-core" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ffb6a0cd5f182dc964334388560b12a57f7b74b3e2dec5e2722aa2dfb2ccd5" -dependencies = [ - "bitflags 2.9.0", - "objc2 0.6.1", - "objc2-foundation 0.3.1", -] - [[package]] name = "objc2-ui-kit" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25b1312ad7bc8a0e92adae17aa10f90aae1fb618832f9b993b022b591027daed" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.11.0", "objc2 0.6.1", "objc2-core-foundation", "objc2-foundation 0.3.1", @@ -2097,7 +2825,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91672909de8b1ce1c2252e95bbee8c1649c9ad9d14b9248b3d7b4c47903c47ad" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.11.0", "block2 0.6.1", "objc2 0.6.1", "objc2-app-kit", @@ -2110,6 +2838,18 @@ name = "object" version = "0.36.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "crc32fast", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "memchr", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ "memchr", ] @@ -2132,6 +2872,50 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "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.101", +] + +[[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.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -2196,6 +2980,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pathdiff" version = "0.2.3" @@ -2367,7 +3157,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eac26e981c03a6e53e0aee43c113e3202f5581d5360dae7bd2c70e800dd0451d" dependencies = [ "base64 0.22.1", - "indexmap 2.9.0", + "indexmap 2.13.0", "quick-xml", "serde", "time", @@ -2386,6 +3176,18 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -2474,6 +3276,27 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "pulley-interpreter" +version = "26.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33e7f8a43ccc7f93b330fef4baf271764674926f3f4d40f4a196d54de8af26" +dependencies = [ + "cranelift-bitset", + "log", + "sptr", +] + [[package]] name = "quick-xml" version = "0.32.0" @@ -2483,6 +3306,61 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.12", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.2", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.12", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.40" @@ -2523,6 +3401,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -2543,6 +3431,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -2561,6 +3459,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.2", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -2579,6 +3486,12 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2591,7 +3504,18 @@ version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", ] [[package]] @@ -2605,6 +3529,19 @@ dependencies = [ "thiserror 2.0.12", ] +[[package]] +name = "regalloc2" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12908dbeb234370af84d0579b9f68258a0f67e201412dd9a2814e6f45b2fc0f0" +dependencies = [ + "hashbrown 0.14.5", + "log", + "rustc-hash", + "slice-group-by", + "smallvec", +] + [[package]] name = "regex" version = "1.11.1" @@ -2636,69 +3573,272 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "reqwest" -version = "0.12.15" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", + "futures-channel", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", + "hyper-rustls", + "hyper-tls", "hyper-util", - "ipnet", "js-sys", "log", "mime", - "once_cell", + "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", "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", + "wasm-streams 0.4.2", "web-sys", - "windows-registry", + "webpki-roots", ] [[package]] -name = "rustc-demangle" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" - -[[package]] -name = "rustc_version" -version = "0.4.1" +name = "reqwest" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ - "semver", + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", ] [[package]] -name = "rustversion" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" - -[[package]] -name = "ryu" -version = "1.0.20" +name = "rfd" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2 0.6.1", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2 0.6.1", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "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.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rust_engineio" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d3572ceba6c5d79eecedf3be93640ca9512fa4100dff6a70f96c514adf4f1f" +dependencies = [ + "adler32", + "async-stream", + "async-trait", + "base64 0.21.7", + "bytes", + "futures-util", + "http", + "native-tls", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-tungstenite", + "tungstenite", + "url", +] + +[[package]] +name = "rust_socketio" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6a8672db895d567b3c0b8a4c0d3e98113ebb32badf6ce66004e743e5ee1e1e" +dependencies = [ + "adler32", + "backoff", + "base64 0.21.7", + "bytes", + "log", + "native-tls", + "rand 0.8.5", + "rust_engineio", + "serde", + "serde_json", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[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 = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix 1.1.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + [[package]] name = "same-file" version = "1.0.6" @@ -2708,6 +3848,15 @@ 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" @@ -2741,6 +3890,29 @@ 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.11.0", + "core-foundation 0.10.0", + "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.24.0" @@ -2770,10 +3942,11 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ + "serde_core", "serde_derive", ] @@ -2788,11 +3961,20 @@ dependencies = [ "typeid", ] +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -2842,6 +4024,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2864,7 +4055,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.9.0", + "indexmap 2.13.0", "serde", "serde_derive", "serde_json", @@ -2886,9 +4077,9 @@ dependencies = [ [[package]] name = "serialize-to-javascript" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9823f2d3b6a81d98228151fdeaf848206a7855a7a042bbf9bf870449a66cafb" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" dependencies = [ "serde", "serde_json", @@ -2897,13 +4088,13 @@ dependencies = [ [[package]] name = "serialize-to-javascript-impl" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74064874e9f6a15f04c1f3cb627902d0e6b410abbf36668afa873c61889f1763" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.101", ] [[package]] @@ -2916,6 +4107,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2937,6 +4139,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "shellexpand" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ccc8076840c4da029af4f87e4e8daeb0fca6b87bbb02e10cb60b791450e11e4" +dependencies = [ + "dirs 4.0.0", +] + [[package]] name = "shlex" version = "1.3.0" @@ -2970,11 +4181,20 @@ dependencies = [ "autocfg", ] +[[package]] +name = "slice-group-by" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "826167069c09b99d56f31e9ae5c99049e932a98c9dc2dac47645b08dbbf76ba7" + [[package]] name = "smallvec" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -2994,13 +4214,13 @@ checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08" dependencies = [ "bytemuck", "cfg_aliases", - "core-graphics", - "foreign-types", + "core-graphics 0.24.0", + "foreign-types 0.5.0", "js-sys", "log", "objc2 0.5.2", "objc2-foundation 0.2.2", - "objc2-quartz-core 0.2.2", + "objc2-quartz-core", "raw-window-handle", "redox_syscall", "wasm-bindgen", @@ -3034,6 +4254,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "sptr" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a" + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -3065,12 +4291,29 @@ dependencies = [ "quote", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[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" @@ -3124,6 +4367,27 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3133,28 +4397,44 @@ dependencies = [ "cfg-expr", "heck 0.5.0", "pkg-config", - "toml", + "toml 0.8.22", "version-compare", ] +[[package]] +name = "system-interface" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4592f674ce18521c2a81483873a49596655b179f71c5e05d10c1fe66c78745" +dependencies = [ + "bitflags 2.11.0", + "cap-fs-ext", + "cap-std", + "fd-lock", + "io-lifetimes", + "rustix 0.38.44", + "windows-sys 0.59.0", + "winx", +] + [[package]] name = "tao" -version = "0.34.0" +version = "0.34.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49c380ca75a231b87b6c9dd86948f035012e7171d1a7c40a9c2890489a7ffd8a" +checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" dependencies = [ - "bitflags 2.9.0", - "core-foundation", - "core-graphics", + "bitflags 2.11.0", + "block2 0.6.1", + "core-foundation 0.10.0", + "core-graphics 0.25.0", "crossbeam-channel", - "dispatch", + "dispatch2", "dlopen2", "dpi", "gdkwayland-sys", "gdkx11-sys", "gtk", "jni", - "lazy_static", "libc", "log", "ndk", @@ -3166,7 +4446,6 @@ dependencies = [ "once_cell", "parking_lot", "raw-window-handle", - "scopeguard", "tao-macros", "unicode-segmentation", "url", @@ -3195,13 +4474,14 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.6.2" +version = "2.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "124e129c9c0faa6bec792c5948c89e86c90094133b0b9044df0ce5f0a8efaa0d" +checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" dependencies = [ "anyhow", "bytes", - "dirs", + "cookie", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.2", @@ -3218,10 +4498,11 @@ dependencies = [ "objc2-app-kit", "objc2-foundation 0.3.1", "objc2-ui-kit", + "objc2-web-kit", "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.2", "serde", "serde_json", "serde_repr", @@ -3236,7 +4517,6 @@ dependencies = [ "tokio", "tray-icon", "url", - "urlpattern", "webkit2gtk", "webview2-com", "window-vibrancy", @@ -3245,13 +4525,13 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.3.0" +version = "2.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f025c389d3adb83114bec704da973142e82fc6ec799c7c750c5e21cefaec83" +checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -3261,15 +4541,15 @@ dependencies = [ "serde_json", "tauri-utils", "tauri-winres", - "toml", + "toml 0.9.12+spec-1.1.0", "walkdir", ] [[package]] name = "tauri-codegen" -version = "2.3.0" +version = "2.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5df493a1075a241065bc865ed5ef8d0fbc1e76c7afdc0bf0eccfaa7d4f0e406" +checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" dependencies = [ "base64 0.22.1", "brotli", @@ -3294,9 +4574,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.3.1" +version = "2.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f237fbea5866fa5f2a60a21bea807a2d6e0379db070d89c3a10ac0f2d4649bbc" +checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3308,9 +4588,9 @@ dependencies = [ [[package]] name = "tauri-plugin" -version = "2.3.0" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d9a0bd00bf1930ad1a604d08b0eb6b2a9c1822686d65d7f4731a7723b8901d3" +checksum = "ddde7d51c907b940fb573006cdda9a642d6a7c8153657e88f8a5c3c9290cd4aa" dependencies = [ "anyhow", "glob", @@ -3319,46 +4599,86 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "toml", + "toml 0.9.12+spec-1.1.0", "walkdir", ] [[package]] -name = "tauri-plugin-process" -version = "2.3.0" +name = "tauri-plugin-dialog" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7461c622a5ea00eb9cd9f7a08dbd3bf79484499fd5c21aa2964677f64ca651ab" +checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b" dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", "tauri", "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.12", + "url", ] [[package]] -name = "tauri-plugin-shell" -version = "2.3.0" +name = "tauri-plugin-fs" +version = "2.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b9ffadec5c3523f11e8273465cacb3d86ea7652a28e6e2a2e9b5c182f791d25" +checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804" dependencies = [ - "encoding_rs", - "log", - "open", - "os_pipe", - "regex", + "anyhow", + "dunce", + "glob", + "percent-encoding", "schemars", "serde", "serde_json", - "shared_child", + "serde_repr", "tauri", "tauri-plugin", + "tauri-utils", "thiserror 2.0.12", - "tokio", + "toml 0.9.12+spec-1.1.0", + "url", ] [[package]] -name = "tauri-runtime" -version = "2.7.0" +name = "tauri-plugin-process" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e7bb73d1bceac06c20b3f755b2c8a2cb13b20b50083084a8cf3700daf397ba4" +checksum = "7461c622a5ea00eb9cd9f7a08dbd3bf79484499fd5c21aa2964677f64ca651ab" +dependencies = [ + "tauri", + "tauri-plugin", +] + +[[package]] +name = "tauri-plugin-shell" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b9ffadec5c3523f11e8273465cacb3d86ea7652a28e6e2a2e9b5c182f791d25" +dependencies = [ + "encoding_rs", + "log", + "open", + "os_pipe", + "regex", + "schemars", + "serde", + "serde_json", + "shared_child", + "tauri", + "tauri-plugin", + "thiserror 2.0.12", + "tokio", +] + +[[package]] +name = "tauri-runtime" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" dependencies = [ "cookie", "dpi", @@ -3367,20 +4687,23 @@ dependencies = [ "jni", "objc2 0.6.1", "objc2-ui-kit", + "objc2-web-kit", "raw-window-handle", "serde", "serde_json", "tauri-utils", "thiserror 2.0.12", "url", + "webkit2gtk", + "webview2-com", "windows", ] [[package]] name = "tauri-runtime-wry" -version = "2.7.1" +version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "902b5aa9035e16f342eb64f8bf06ccdc2808e411a2525ed1d07672fa4e780bad" +checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" dependencies = [ "gtk", "http", @@ -3388,7 +4711,6 @@ dependencies = [ "log", "objc2 0.6.1", "objc2-app-kit", - "objc2-foundation 0.3.1", "once_cell", "percent-encoding", "raw-window-handle", @@ -3405,9 +4727,9 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.5.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41743bbbeb96c3a100d234e5a0b60a46d5aa068f266160862c7afdbf828ca02e" +checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" dependencies = [ "anyhow", "brotli", @@ -3434,7 +4756,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.12", - "toml", + "toml 0.9.12+spec-1.1.0", "url", "urlpattern", "uuid", @@ -3448,8 +4770,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8d321dbc6f998d825ab3f0d62673e810c861aac2d0de2cc2c395328f1d113b4" dependencies = [ "embed-resource", - "indexmap 2.9.0", - "toml", + "indexmap 2.13.0", + "toml 0.8.22", +] + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.2", + "once_cell", + "rustix 1.1.2", + "windows-sys 0.61.2", ] [[package]] @@ -3463,6 +4798,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3544,6 +4888,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.44.2" @@ -3559,6 +4918,40 @@ dependencies = [ "windows-sys 0.52.0", ] +[[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-tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.15" @@ -3579,11 +4972,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05ae329d1f08c4d17a59bed7ff5b5a769d062e64a62d34a3261b219e62cd5aae" dependencies = [ "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.8", + "toml_datetime 0.6.9", "toml_edit 0.22.26", ] +[[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.13.0", + "serde_core", + "serde_spanned 1.1.0", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + [[package]] name = "toml_datetime" version = "0.6.9" @@ -3593,14 +5001,23 @@ 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_edit" version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.9.0", - "toml_datetime", + "indexmap 2.13.0", + "toml_datetime 0.6.9", "winnow 0.5.40", ] @@ -3610,8 +5027,8 @@ version = "0.20.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" dependencies = [ - "indexmap 2.9.0", - "toml_datetime", + "indexmap 2.13.0", + "toml_datetime 0.6.9", "winnow 0.5.40", ] @@ -3621,12 +5038,21 @@ version = "0.22.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e" dependencies = [ - "indexmap 2.9.0", + "indexmap 2.13.0", "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.8", + "toml_datetime 0.6.9", "toml_write", - "winnow 0.7.9", + "winnow 0.7.15", +] + +[[package]] +name = "toml_parser" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +dependencies = [ + "winnow 1.0.1", ] [[package]] @@ -3635,6 +5061,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfb942dfe1d8e29a7ee7fcbde5bd2b9a25fb89aa70caea2eba3bee836ff41076" +[[package]] +name = "toml_writer" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" + [[package]] name = "tower" version = "0.5.2" @@ -3650,6 +5082,24 @@ dependencies = [ "tower-service", ] +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -3669,9 +5119,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "tracing-core" version = "0.1.33" @@ -3688,7 +5150,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2da75ec677957aa21f6e0b361df0daab972f13a5bee3606de0638fd4ee1c666a" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2 0.6.1", @@ -3709,6 +5171,26 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "url", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -3762,18 +5244,57 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[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.4" @@ -3817,176 +5338,506 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] -name = "uuid" -version = "1.16.0" +name = "uuid" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" +dependencies = [ + "getrandom 0.3.2", + "serde", +] + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" + +[[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.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dc0882f7b5bb01ae8c5215a1230832694481c1a4be062fd410e12ea3da5b631" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19280959e2844181895ef62f065c63e0ca07ece4771b53d89bfdb967d97cbf05" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75973d3066e01d035dbedaad2864c398df42f8dd7b1ea057c35b8407c015b537" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91af5e4be765819e0bcfee7322c14374dc821e35e72fa663a830bbc7dc199eac" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.101", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9bf0406a78f02f336bf1e451799cca198e8acde4ffa278f0fb20487b150a633" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.218.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "491f7e48672d0a1efdeadf897d98ac1f45942c26c3829cb44a6b828f6f26155f" +dependencies = [ + "leb128", +] + +[[package]] +name = "wasm-encoder" +version = "0.246.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61fb705ce81adde29d2a8e99d87995e39a6e927358c91398f374474746070ef7" +dependencies = [ + "leb128fmt", + "wasmparser 0.246.2", +] + +[[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 = "wasmparser" +version = "0.218.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "059739c2eac26eea736389a7d6d30b41a8201490bea204d0facde19183359849" +dependencies = [ + "ahash", + "bitflags 2.11.0", + "hashbrown 0.14.5", + "indexmap 2.13.0", + "semver", + "serde", +] + +[[package]] +name = "wasmparser" +version = "0.246.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71cde4757396defafd25417cfb36aa3161027d06d865b0c24baaae229aac005d" +dependencies = [ + "bitflags 2.11.0", + "indexmap 2.13.0", + "semver", +] + +[[package]] +name = "wasmprinter" +version = "0.218.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b30ceafa77646f56747369b0f2a0296016a40b447d32e6907439f2e4bb7695" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.218.1", +] + +[[package]] +name = "wasmtime" +version = "26.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e762e163fd305770c6c341df3290f0cabb3c264e7952943018e9a1ced8d917" +dependencies = [ + "anyhow", + "async-trait", + "bitflags 2.11.0", + "bumpalo", + "cc", + "cfg-if", + "encoding_rs", + "hashbrown 0.14.5", + "indexmap 2.13.0", + "libc", + "libm", + "log", + "mach2", + "memfd", + "object 0.36.7", + "once_cell", + "paste", + "postcard", + "psm", + "pulley-interpreter", + "rustix 0.38.44", + "semver", + "serde", + "serde_derive", + "smallvec", + "sptr", + "target-lexicon", + "wasmparser 0.218.1", + "wasmtime-asm-macros", + "wasmtime-component-macro", + "wasmtime-component-util", + "wasmtime-cranelift", + "wasmtime-environ", + "wasmtime-fiber", + "wasmtime-jit-icache-coherence", + "wasmtime-slab", + "wasmtime-versioned-export-macros", + "wasmtime-winch", + "windows-sys 0.59.0", +] + +[[package]] +name = "wasmtime-asm-macros" +version = "26.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" +checksum = "63caa7aebb546374e26257a1900fb93579171e7c02514cde26805b9ece3ef812" dependencies = [ - "getrandom 0.3.2", - "serde", + "cfg-if", ] [[package]] -name = "version-compare" -version = "0.2.0" +name = "wasmtime-component-macro" +version = "26.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" +checksum = "d61a4b5ce2ad9c15655e830f0eac0c38b8def30c74ecac71f452d3901e491b68" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn 2.0.101", + "wasmtime-component-util", + "wasmtime-wit-bindgen", + "wit-parser", +] [[package]] -name = "version_check" -version = "0.9.5" +name = "wasmtime-component-util" +version = "26.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +checksum = "35e87a1212270dbb84a49af13d82594e00a92769d6952b0ea7fc4366c949f6ad" [[package]] -name = "vswhom" -version = "0.1.0" +name = "wasmtime-cranelift" +version = "26.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +checksum = "7cb40dddf38c6a5eefd5ce7c1baf43b00fe44eada11a319fab22e993a960262f" dependencies = [ - "libc", - "vswhom-sys", + "anyhow", + "cfg-if", + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools", + "log", + "object 0.36.7", + "smallvec", + "target-lexicon", + "thiserror 1.0.69", + "wasmparser 0.218.1", + "wasmtime-environ", + "wasmtime-versioned-export-macros", ] [[package]] -name = "vswhom-sys" -version = "0.1.3" +name = "wasmtime-environ" +version = "26.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +checksum = "8613075e89e94a48c05862243c2b718eef1b9c337f51493ebf951e149a10fa19" dependencies = [ - "cc", - "libc", + "anyhow", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "indexmap 2.13.0", + "log", + "object 0.36.7", + "postcard", + "semver", + "serde", + "serde_derive", + "smallvec", + "target-lexicon", + "wasm-encoder 0.218.1", + "wasmparser 0.218.1", + "wasmprinter", + "wasmtime-component-util", ] [[package]] -name = "walkdir" -version = "2.5.0" +name = "wasmtime-fiber" +version = "26.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +checksum = "77acabfbcd89a4d47ad117fb31e340c824e2f49597105402c3127457b6230995" dependencies = [ - "same-file", - "winapi-util", + "anyhow", + "cc", + "cfg-if", + "rustix 0.38.44", + "wasmtime-asm-macros", + "wasmtime-versioned-export-macros", + "windows-sys 0.59.0", ] [[package]] -name = "want" -version = "0.3.1" +name = "wasmtime-jit-icache-coherence" +version = "26.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "da47fba49af72581bc0dc67c8faaf5ee550e6f106e285122a184a675193701a5" dependencies = [ - "try-lock", + "anyhow", + "cfg-if", + "libc", + "windows-sys 0.59.0", ] [[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - -[[package]] -name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +name = "wasmtime-slab" +version = "26.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "770e10cdefb15f2b6304152978e115bd062753c1ebe7221c0b6b104fa0419ff6" [[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" +name = "wasmtime-versioned-export-macros" +version = "26.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "db8efb877c9e5e67239d4553bb44dd2a34ae5cfb728f3cf2c5e64439c6ca6ee7" dependencies = [ - "wit-bindgen-rt", + "proc-macro2", + "quote", + "syn 2.0.101", ] [[package]] -name = "wasm-bindgen" -version = "0.2.100" +name = "wasmtime-wasi" +version = "26.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "f16c8d87a45168131be6815045e6d46d7f6ddf65897c49444ab210488bce10dc" dependencies = [ - "cfg-if", + "anyhow", + "async-trait", + "bitflags 2.11.0", + "bytes", + "cap-fs-ext", + "cap-net-ext", + "cap-rand", + "cap-std", + "cap-time-ext", + "fs-set-times", + "futures", + "io-extras", + "io-lifetimes", "once_cell", - "rustversion", - "wasm-bindgen-macro", + "rustix 0.38.44", + "system-interface", + "thiserror 1.0.69", + "tokio", + "tracing", + "url", + "wasmtime", + "wiggle", + "windows-sys 0.59.0", ] [[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" +name = "wasmtime-winch" +version = "26.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "4f7a267367382ceec3e7f7ace63a63b83d86f4a680846743dead644e10f08150" dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.101", - "wasm-bindgen-shared", + "anyhow", + "cranelift-codegen", + "gimli", + "object 0.36.7", + "target-lexicon", + "wasmparser 0.218.1", + "wasmtime-cranelift", + "wasmtime-environ", + "winch-codegen", ] [[package]] -name = "wasm-bindgen-futures" -version = "0.4.50" +name = "wasmtime-wit-bindgen" +version = "26.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "4bef2a726fd8d1ee9b0144655e16c492dc32eb4c7c9f7e3309fcffe637870933" dependencies = [ - "cfg-if", - "js-sys", - "once_cell", - "wasm-bindgen", - "web-sys", + "anyhow", + "heck 0.5.0", + "indexmap 2.13.0", + "wit-parser", ] [[package]] -name = "wasm-bindgen-macro" -version = "0.2.100" +name = "wast" +version = "35.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "2ef140f1b49946586078353a453a1d28ba90adfc54dde75710bc1931de204d68" dependencies = [ - "quote", - "wasm-bindgen-macro-support", + "leb128", ] [[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.100" +name = "wast" +version = "246.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "fe3fe8e3bf88ad96d031b4181ddbd64634b17cb0d06dfc3de589ef43591a9a62" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", - "wasm-bindgen-backend", - "wasm-bindgen-shared", + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder 0.246.2", ] [[package]] -name = "wasm-bindgen-shared" -version = "0.2.100" +name = "wat" +version = "1.246.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "4bd7fda1199b94fff395c2d19a153f05dbe7807630316fa9673367666fd2ad8c" dependencies = [ - "unicode-ident", + "wast 246.0.2", ] [[package]] -name = "wasm-streams" -version = "0.4.2" +name = "web-sys" +version = "0.3.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "749466a37ee189057f54748b200186b59a03417a117267baf3fd89cecc9fb837" dependencies = [ - "futures-util", "js-sys", "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", ] [[package]] -name = "web-sys" -version = "0.3.77" +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", "wasm-bindgen", @@ -3994,9 +5845,9 @@ dependencies = [ [[package]] name = "webkit2gtk" -version = "2.0.1" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" dependencies = [ "bitflags 1.3.2", "cairo-rs", @@ -4018,9 +5869,9 @@ dependencies = [ [[package]] name = "webkit2gtk-sys" -version = "2.0.1" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" dependencies = [ "bitflags 1.3.2", "cairo-sys-rs", @@ -4036,6 +5887,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.0" @@ -4072,6 +5932,54 @@ dependencies = [ "windows-core", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wiggle" +version = "26.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f25588cf5ea16f56c1af13244486d50c5a2cf67cc0c4e990c665944d741546" +dependencies = [ + "anyhow", + "async-trait", + "bitflags 2.11.0", + "thiserror 1.0.69", + "tracing", + "wasmtime", + "wiggle-macro", +] + +[[package]] +name = "wiggle-generate" +version = "26.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28ff23bed568b335dac6a324b8b167318a0c60555199445fcc89745a5eb42452" +dependencies = [ + "anyhow", + "heck 0.5.0", + "proc-macro2", + "quote", + "shellexpand", + "syn 2.0.101", + "witx", +] + +[[package]] +name = "wiggle-macro" +version = "26.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f13be83541aa0b033ac5ec8a8b59c9a8d8b32305845b8466dd066e722cb0004" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", + "wiggle-generate", +] + [[package]] name = "winapi" version = "0.3.9" @@ -4103,6 +6011,23 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "winch-codegen" +version = "26.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07ab957fc71a36c63834b9b51cc2e087c4260d5ff810a5309ab99f7fbeb19567" +dependencies = [ + "anyhow", + "cranelift-codegen", + "gimli", + "regalloc2", + "smallvec", + "target-lexicon", + "wasmparser 0.218.1", + "wasmtime-cranelift", + "wasmtime-environ", +] + [[package]] name = "window-vibrancy" version = "0.6.0" @@ -4127,7 +6052,7 @@ dependencies = [ "windows-collections", "windows-core", "windows-future", - "windows-link", + "windows-link 0.1.1", "windows-numerics", ] @@ -4148,8 +6073,8 @@ checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" dependencies = [ "windows-implement", "windows-interface", - "windows-link", - "windows-result", + "windows-link 0.1.1", + "windows-result 0.3.2", "windows-strings 0.4.0", ] @@ -4160,7 +6085,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a1d6bbefcb7b60acd19828e1bc965da6fcf18a7e39490c5f8be71e54a19ba32" dependencies = [ "windows-core", - "windows-link", + "windows-link 0.1.1", ] [[package]] @@ -4191,6 +6116,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" +[[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" @@ -4198,18 +6129,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ "windows-core", - "windows-link", + "windows-link 0.1.1", ] [[package]] name = "windows-registry" -version = "0.4.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-result", - "windows-strings 0.3.1", - "windows-targets 0.53.0", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -4218,16 +6149,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" dependencies = [ - "windows-link", + "windows-link 0.1.1", ] [[package]] -name = "windows-strings" -version = "0.3.1" +name = "windows-result" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -4236,7 +6167,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" dependencies = [ - "windows-link", + "windows-link 0.1.1", +] + +[[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]] @@ -4275,6 +6215,24 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-targets" version = "0.42.2" @@ -4323,10 +6281,11 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.0" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ + "windows-link 0.2.1", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -4343,7 +6302,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e04a5c6627e310a23ad2358483286c7df260c964eb2d003d8efd6d0f4e79265c" dependencies = [ - "windows-link", + "windows-link 0.1.1", ] [[package]] @@ -4537,13 +6496,19 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.9" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9fb597c990f03753e08d3c29efbfcf2019a003b4bf4ba19225c158e1549f0f3" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" + [[package]] name = "winreg" version = "0.52.0" @@ -4554,13 +6519,53 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags 2.11.0", + "windows-sys 0.59.0", +] + [[package]] name = "wit-bindgen-rt" version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.11.0", +] + +[[package]] +name = "wit-parser" +version = "0.218.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f104473e8546f8096f1fa483d337101a98dc9525d67f4275816bcd177fe3e2be" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.218.1", +] + +[[package]] +name = "witx" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e366f27a5cabcddb2706a78296a40b8fcc451e1a6aba2fc1d94b4a01bdaaef4b" +dependencies = [ + "anyhow", + "log", + "thiserror 1.0.69", + "wast 35.0.2", ] [[package]] @@ -4577,14 +6582,15 @@ checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" [[package]] name = "wry" -version = "0.52.1" +version = "0.54.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12a714d9ba7075aae04a6e50229d6109e3d584774b99a6a8c60de1698ca111b9" +checksum = "bb26159b420aa77684589a744ae9a9461a95395b848764ad12290a14d960a11a" dependencies = [ "base64 0.22.1", "block2 0.6.1", "cookie", "crossbeam-channel", + "dirs 6.0.0", "dpi", "dunce", "gdkx11", @@ -4705,6 +6711,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerovec" version = "0.10.4" @@ -4726,3 +6738,31 @@ dependencies = [ "quote", "syn 2.0.101", ] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 8730f8348..fdab84537 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -14,8 +14,22 @@ tauri-build = { version = "2.0.3", features = [] } tauri = { version = "2.1.1", features = [] } serde = { version = "1", features = ["derive"] } serde_json = "1" +chrono = { version = "0.4", features = ["clock"] } tauri-plugin-shell = "2" tauri-plugin-process = "2.3.0" +tauri-plugin-dialog = "2.6.0" +reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } +futures-util = "0.3" +rust_socketio = "0.6" +glob = "0.3" +regex = "1" +sha2 = "0.10" +zstd = "0.13" +wasmtime = { version = "26", default-features = false, features = ["cranelift", "runtime"] } +wasmtime-wasi = { version = "26", default-features = false, features = ["preview1"] } +wat = "1.218" +lopdf = { version = "0.36", default-features = false } +tokio = { version = "1", features = ["rt", "sync", "time"] } [features] # This feature is used for production builds or when a dev server is not specified, DO NOT REMOVE!! diff --git a/frontend/src-tauri/capabilities/migrated.json b/frontend/src-tauri/capabilities/migrated.json index 9e7c17be9..52103c575 100644 --- a/frontend/src-tauri/capabilities/migrated.json +++ b/frontend/src-tauri/capabilities/migrated.json @@ -7,7 +7,10 @@ ], "permissions": [ "core:default", + "core:window:allow-set-focus", + "dialog:default", "process:default", - "shell:default" + "shell:default", + "shell:allow-open" ] -} \ No newline at end of file +} diff --git a/frontend/src-tauri/src/cowork/agent_presets/homepage.rs b/frontend/src-tauri/src/cowork/agent_presets/homepage.rs new file mode 100644 index 000000000..facaae060 --- /dev/null +++ b/frontend/src-tauri/src/cowork/agent_presets/homepage.rs @@ -0,0 +1,14 @@ +use super::shared; +use crate::cowork::chat::CoworkAgentOverrides; + +pub fn homepage_builtin_agent_overrides(_prompt_context: Option<&str>) -> CoworkAgentOverrides { + CoworkAgentOverrides { + system_prompt: Some( + "You are the Cowork homepage agent inside II Agent desktop. Help the user reason over local cowork context, use tools deliberately, and keep responses concise and actionable." + .to_string(), + ), + tool_names: Some(shared::base_tool_names()), + skill_names: None, + runtime_options: Some(shared::default_runtime_options("homepage_cowork_agent")), + } +} diff --git a/frontend/src-tauri/src/cowork/agent_presets/intelligent_folder.rs b/frontend/src-tauri/src/cowork/agent_presets/intelligent_folder.rs new file mode 100644 index 000000000..f7967cc4e --- /dev/null +++ b/frontend/src-tauri/src/cowork/agent_presets/intelligent_folder.rs @@ -0,0 +1,238 @@ +use super::shared; +use super::shared::{DesktopCapabilities, DesktopSkillCapability, DesktopToolCapability}; +use super::DesktopRuntimePreset; +use crate::cowork::chat::CoworkAgentOverrides; +use crate::cowork::desktop_skills::DesktopSkill; +use crate::cowork::desktop_tools::{DesktopExecutionScope, DesktopTool}; +use crate::cowork::intelligent_folder::capabilities; +use crate::cowork::intelligent_folder::sessions; +use crate::cowork::session_gateway::{self, LocalCoworkSession}; +use std::fs; +use tauri::AppHandle; + +pub fn folder_builtin_agent_overrides(prompt_context: Option<&str>) -> CoworkAgentOverrides { + CoworkAgentOverrides { + system_prompt: Some(build_folder_system_prompt(prompt_context)), + tool_names: Some(build_folder_tool_names()), + skill_names: None, + runtime_options: Some(shared::default_runtime_options("folder_cowork_agent")), + } +} + +pub fn build_folder_desktop_capabilities() -> DesktopCapabilities { + DesktopCapabilities { + tools: merge_tool_capabilities( + shared::desktop_built_tools(), + capabilities::desktop_tool_capabilities(), + ), + skills: build_folder_desktop_skills(), + } +} + +pub fn build_folder_desktop_runtime() -> DesktopRuntimePreset { + DesktopRuntimePreset { + tools: build_folder_runtime_tools(), + skills: build_folder_runtime_skills(), + load_execution_scope: load_folder_execution_scope, + refresh_scope: Some(refresh_folder_execution_scope), + } +} + +fn build_folder_desktop_tools() -> Vec { + build_folder_desktop_capabilities().tools +} + +fn build_folder_desktop_skills() -> Vec { + merge_skill_capabilities( + shared::desktop_built_skills(), + capabilities::desktop_skill_capabilities(), + ) +} + +fn build_folder_tool_names() -> Vec { + build_folder_desktop_tools() + .into_iter() + .map(|tool| tool.name) + .collect() +} + +fn build_folder_runtime_tools() -> Vec { + merge_runtime_tools( + shared::desktop_runtime_tools(), + capabilities::desktop_tools(), + ) +} + +fn build_folder_runtime_skills() -> Vec { + merge_runtime_skills( + shared::desktop_runtime_skills(), + capabilities::desktop_runtime_skills(), + ) +} + +fn build_folder_system_prompt(prompt_context: Option<&str>) -> String { + let mut prompt = "You are the Cowork folder agent inside II Agent desktop.\n\ +Focus on analyzing and reorganizing the user's local file and folder structure with careful, tool-assisted reasoning.\n\ +Treat this mode as intelligent-folder scope for a desktop-selected folder.\n\ +Use only the built desktop tools provided for local file inspection and edits.\n\ +Do not assume backend-only, browser, connector, repository, or web tools exist in this mode." + .to_string(); + + if let Some(source_root) = extract_prompt_value(prompt_context, "Input folder path:") + .or_else(|| extract_prompt_value(prompt_context, "Source root:")) + { + prompt.push_str("\n\n[Folder scope]"); + prompt.push_str("\nMode scope: intelligent-folder"); + prompt.push_str("\nInput folder path: "); + prompt.push_str(&source_root); + + if let Some(result_root) = extract_prompt_value(prompt_context, "Result root:") { + prompt.push_str("\nResult folder path: "); + prompt.push_str(&result_root); + } + } + + prompt +} + +fn extract_prompt_value(prompt_context: Option<&str>, label: &str) -> Option { + let prompt_context = prompt_context?; + + prompt_context.lines().find_map(|line| { + line.strip_prefix(label) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }) +} + +fn merge_tool_capabilities( + common_tools: Vec, + mode_tools: Vec, +) -> Vec { + let mut merged = common_tools; + for tool in mode_tools { + if merged.iter().any(|existing| existing.name == tool.name) { + continue; + } + merged.push(tool); + } + merged +} + +fn merge_runtime_tools( + common_tools: Vec, + mode_tools: Vec, +) -> Vec { + let mut merged = common_tools; + for tool in mode_tools { + if merged.iter().any(|existing| existing.name() == tool.name()) { + continue; + } + merged.push(tool); + } + merged +} + +fn merge_skill_capabilities( + common_skills: Vec, + mode_skills: Vec, +) -> Vec { + let mut merged = common_skills; + for skill in mode_skills { + if merged.iter().any(|existing| existing.name == skill.name) { + continue; + } + merged.push(skill); + } + merged +} + +fn merge_runtime_skills( + common_skills: Vec, + mode_skills: Vec, +) -> Vec { + let mut merged = common_skills; + for skill in mode_skills { + if merged + .iter() + .any(|existing| existing.name() == skill.name()) + { + continue; + } + merged.push(skill); + } + merged +} + +fn load_folder_execution_scope( + app: &AppHandle, + local_session_id: &str, +) -> Result { + let local_session = session_gateway::load_local_session( + app, + crate::cowork::chat::CoworkChatScope::IntelligentFolder, + local_session_id, + )?; + let LocalCoworkSession::Folder(detail) = local_session else { + return Err("Desktop folder tool execution requires an folder session".to_string()); + }; + + let root_path = detail.folder_tree_pair.source_root.clone(); + let canonical_root = fs::canonicalize(&root_path).map_err(|error| { + format!( + "Failed to resolve folder source_root {}: {}", + root_path, error + ) + })?; + + Ok(DesktopExecutionScope::new(canonical_root)) +} + +fn refresh_folder_execution_scope( + app: &AppHandle, + local_session_id: &str, + _execution_scope: &DesktopExecutionScope, +) -> Result<(), String> { + let local_session = session_gateway::load_local_session( + app, + crate::cowork::chat::CoworkChatScope::IntelligentFolder, + local_session_id, + )?; + let LocalCoworkSession::Folder(mut detail) = local_session else { + return Ok(()); + }; + sessions::sync_result_tree_from_disk(&mut detail)?; + session_gateway::persist_local_session(app, LocalCoworkSession::Folder(detail))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_folder_system_prompt_embeds_local_scope() { + let prompt = build_folder_system_prompt(Some( + "[Local folder scope]\nInput folder path: C:/Users/demo/Documents\nResult root: C:/Users/demo/Documents", + )); + + assert!(prompt.contains("Mode scope: intelligent-folder")); + assert!(prompt.contains("Input folder path: C:/Users/demo/Documents")); + assert!(prompt.contains("Result folder path: C:/Users/demo/Documents")); + assert!(prompt.contains("Use only the built desktop tools")); + } + + #[test] + fn build_folder_desktop_capabilities_match_override_tool_names() { + let overrides = folder_builtin_agent_overrides(None); + let capabilities = build_folder_desktop_capabilities(); + let capability_tool_names = capabilities + .tools + .into_iter() + .map(|tool| tool.name) + .collect::>(); + + assert_eq!(overrides.tool_names, Some(capability_tool_names)); + } +} diff --git a/frontend/src-tauri/src/cowork/agent_presets/mod.rs b/frontend/src-tauri/src/cowork/agent_presets/mod.rs new file mode 100644 index 000000000..52d8f886a --- /dev/null +++ b/frontend/src-tauri/src/cowork/agent_presets/mod.rs @@ -0,0 +1,52 @@ +pub mod homepage; +pub mod intelligent_folder; +pub mod shared; + +use crate::cowork::chat::{CoworkAgentOverrides, CoworkChatScope, CoworkChatToolSettings}; +use crate::cowork::desktop_skills::DesktopSkill; +use crate::cowork::desktop_tools::{ + DesktopExecutionScopeLoaderFn, DesktopExecutionScopeRefreshFn, DesktopTool, +}; +use shared::DesktopCapabilities; + +pub struct DesktopRuntimePreset { + pub tools: Vec, + pub skills: Vec, + pub load_execution_scope: DesktopExecutionScopeLoaderFn, + pub refresh_scope: Option, +} + +pub struct ResolvedDesktopPreset { + pub capabilities: DesktopCapabilities, + pub runtime: DesktopRuntimePreset, +} + +pub fn resolve_agent_overrides( + scope: CoworkChatScope, + prompt_context: Option<&str>, + tools: Option<&CoworkChatToolSettings>, + runtime_overrides: Option, +) -> CoworkAgentOverrides { + let builtin = match scope { + CoworkChatScope::Homepage => homepage::homepage_builtin_agent_overrides(prompt_context), + CoworkChatScope::IntelligentFolder => { + intelligent_folder::folder_builtin_agent_overrides(prompt_context) + } + }; + + let merged = shared::merge_agent_overrides(builtin, runtime_overrides); + match scope { + CoworkChatScope::Homepage => shared::apply_tool_settings(merged, tools), + CoworkChatScope::IntelligentFolder => shared::lock_tool_names_to_desktop(merged), + } +} + +pub fn resolve_desktop_preset(scope: CoworkChatScope) -> Option { + match scope { + CoworkChatScope::Homepage => None, + CoworkChatScope::IntelligentFolder => Some(ResolvedDesktopPreset { + capabilities: intelligent_folder::build_folder_desktop_capabilities(), + runtime: intelligent_folder::build_folder_desktop_runtime(), + }), + } +} diff --git a/frontend/src-tauri/src/cowork/agent_presets/shared.rs b/frontend/src-tauri/src/cowork/agent_presets/shared.rs new file mode 100644 index 000000000..ae1d574fd --- /dev/null +++ b/frontend/src-tauri/src/cowork/agent_presets/shared.rs @@ -0,0 +1,236 @@ +use crate::cowork::chat::{CoworkAgentOverrides, CoworkChatToolSettings}; +use crate::cowork::desktop_skills::DesktopSkill; +use crate::cowork::desktop_tools::{resolve_desktop_tool_name, DesktopTool}; +use serde::Serialize; +use serde_json::{Map, Value}; + +#[derive(Debug, Clone, Serialize)] +pub struct DesktopCapabilities { + pub tools: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub skills: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DesktopToolCapability { + pub name: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub aliases: Vec, + pub display_name: String, + pub description: String, + pub input_schema: Value, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DesktopSkillCapability { + pub name: String, + pub description: String, +} + +pub const TOOL_WEB_SEARCH: &str = "web_search"; +pub const TOOL_WEB_VISIT: &str = "web_visit"; + +pub fn base_tool_names() -> Vec { + vec![TOOL_WEB_SEARCH.to_string(), TOOL_WEB_VISIT.to_string()] +} + +pub fn desktop_built_tools() -> Vec { + crate::cowork::desktop_tools::common_desktop_tool_capabilities() +} + +pub fn desktop_runtime_tools() -> Vec { + crate::cowork::desktop_tools::common_desktop_tools() +} + +pub fn desktop_built_tool_names() -> Vec { + crate::cowork::desktop_tools::common_desktop_tool_names() +} + +pub fn desktop_built_skills() -> Vec { + crate::cowork::desktop_skills::common_desktop_skill_capabilities() +} + +pub fn desktop_runtime_skills() -> Vec { + crate::cowork::desktop_skills::common_desktop_skills() +} + +pub fn default_runtime_options(agent_name: &str) -> Value { + Value::Object(Map::from_iter([ + ("name".to_string(), Value::String(agent_name.to_string())), + ("retries".to_string(), Value::from(0)), + ("delay_between_retries".to_string(), Value::from(1)), + ("exponential_backoff".to_string(), Value::Bool(false)), + ("stream".to_string(), Value::Bool(true)), + ("stream_events".to_string(), Value::Bool(true)), + ("store_events".to_string(), Value::Bool(true)), + ("delegate_to_all_members".to_string(), Value::Bool(false)), + ("stream_member_events".to_string(), Value::Bool(true)), + ("store_member_responses".to_string(), Value::Bool(false)), + ])) +} + +pub fn merge_agent_overrides( + builtin: CoworkAgentOverrides, + runtime: Option, +) -> CoworkAgentOverrides { + let Some(runtime) = runtime else { + return builtin; + }; + + CoworkAgentOverrides { + system_prompt: runtime.system_prompt.or(builtin.system_prompt), + tool_names: runtime.tool_names.or(builtin.tool_names), + skill_names: runtime.skill_names.or(builtin.skill_names), + runtime_options: merge_runtime_options( + builtin.runtime_options, + runtime.runtime_options, + ), + } +} + +pub fn apply_tool_settings( + mut overrides: CoworkAgentOverrides, + tools: Option<&CoworkChatToolSettings>, +) -> CoworkAgentOverrides { + let mut tool_names = overrides.tool_names.take().unwrap_or_else(base_tool_names); + let effective_tools = tools.cloned().unwrap_or(CoworkChatToolSettings { + web_search: true, + web_visit: true, + image_search: false, + code_interpreter: None, + generate_image: None, + generate_video: None, + }); + + set_tool_enabled(&mut tool_names, TOOL_WEB_SEARCH, effective_tools.web_search); + set_tool_enabled(&mut tool_names, TOOL_WEB_VISIT, effective_tools.web_visit); + + overrides.tool_names = Some(tool_names); + overrides +} + +pub fn lock_tool_names_to_desktop(mut overrides: CoworkAgentOverrides) -> CoworkAgentOverrides { + let allowed_tool_names = desktop_built_tool_names(); + let requested_tool_names = overrides.tool_names.take(); + let mut filtered_tool_names = Vec::new(); + + for requested_tool_name in requested_tool_names.unwrap_or_else(|| allowed_tool_names.clone()) { + let Some(canonical_tool_name) = resolve_desktop_tool_name(&requested_tool_name) else { + continue; + }; + + if allowed_tool_names + .iter() + .any(|allowed_tool_name| allowed_tool_name == &canonical_tool_name) + && !filtered_tool_names + .iter() + .any(|value| value == &canonical_tool_name) + { + filtered_tool_names.push(canonical_tool_name); + } + } + + overrides.tool_names = Some(if filtered_tool_names.is_empty() { + allowed_tool_names + } else { + filtered_tool_names + }); + overrides +} + +fn merge_runtime_options(builtin: Option, runtime: Option) -> Option { + match (builtin, runtime) { + (None, None) => None, + (Some(base), None) => Some(base), + (None, Some(runtime)) => Some(runtime), + (Some(base), Some(runtime)) => Some(merge_json_values(base, runtime)), + } +} + +fn merge_json_values(base: Value, runtime: Value) -> Value { + match (base, runtime) { + (Value::Object(mut base_map), Value::Object(runtime_map)) => { + for (key, value) in runtime_map { + let merged_value = match base_map.remove(&key) { + Some(existing) => merge_json_values(existing, value), + None => value, + }; + base_map.insert(key, merged_value); + } + Value::Object(base_map) + } + (_, runtime) => runtime, + } +} + +fn set_tool_enabled(tool_names: &mut Vec, tool_name: &str, enabled: bool) { + if enabled { + if !tool_names.iter().any(|value| value == tool_name) { + tool_names.push(tool_name.to_string()); + } + } else { + tool_names.retain(|value| value != tool_name); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cowork::desktop_tools::{TOOL_APPLY_PATCH, TOOL_BASH, TOOL_READ}; + + #[test] + fn lock_tool_names_to_desktop_filters_out_backend_and_web_tools() { + let overrides = CoworkAgentOverrides { + system_prompt: None, + tool_names: Some(vec![ + TOOL_READ.to_string(), + TOOL_WEB_SEARCH.to_string(), + "register_port".to_string(), + TOOL_APPLY_PATCH.to_string(), + ]), + skill_names: None, + runtime_options: None, + }; + + let locked = lock_tool_names_to_desktop(overrides); + + assert_eq!( + locked.tool_names, + Some(vec![TOOL_READ.to_string(), TOOL_APPLY_PATCH.to_string()]) + ); + } + + #[test] + fn lock_tool_names_to_desktop_falls_back_to_desktop_toolset_when_needed() { + let overrides = CoworkAgentOverrides { + system_prompt: None, + tool_names: Some(vec![ + TOOL_WEB_SEARCH.to_string(), + "unknown_tool".to_string(), + ]), + skill_names: None, + runtime_options: None, + }; + + let locked = lock_tool_names_to_desktop(overrides); + + assert_eq!(locked.tool_names, Some(desktop_built_tool_names())); + } + + #[test] + fn lock_tool_names_to_desktop_maps_aliases_to_canonical_names() { + let overrides = CoworkAgentOverrides { + system_prompt: None, + tool_names: Some(vec!["read_file".to_string(), "bash".to_string()]), + skill_names: None, + runtime_options: None, + }; + + let locked = lock_tool_names_to_desktop(overrides); + + assert_eq!( + locked.tool_names, + Some(vec![TOOL_READ.to_string(), TOOL_BASH.to_string()]) + ); + } +} diff --git a/frontend/src-tauri/src/cowork/agent_remote/auth.rs b/frontend/src-tauri/src/cowork/agent_remote/auth.rs new file mode 100644 index 000000000..d647af96e --- /dev/null +++ b/frontend/src-tauri/src/cowork/agent_remote/auth.rs @@ -0,0 +1,53 @@ +use crate::cowork::string_utils::normalize_optional_string; +use std::sync::Mutex; +use tauri::State; + +pub const AUTH_REQUIRED_MESSAGE: &str = + "Current user token is not available in Cowork desktop mode. Please sign in again."; + +#[derive(Debug, Default, Clone)] +pub struct RemoteAuthContext { + pub access_token: Option, + pub api_base_url: Option, +} + +#[derive(Debug, Default)] +pub struct RemoteAuthState { + pub inner: Mutex, +} + +#[tauri::command] +pub fn sync_cowork_auth_context( + state: State<'_, RemoteAuthState>, + access_token: Option, + api_base_url: String, +) -> Result<(), String> { + let normalized_base_url = normalize_api_base_url(&api_base_url)?; + let normalized_token = access_token.and_then(|token| normalize_optional_string(&token)); + + let mut auth_context = state + .inner + .lock() + .map_err(|_| "Failed to lock cowork remote auth context".to_string())?; + + auth_context.access_token = normalized_token; + auth_context.api_base_url = Some(normalized_base_url); + + Ok(()) +} + +pub fn get_auth_context(state: &State<'_, RemoteAuthState>) -> RemoteAuthContext { + state + .inner + .lock() + .map(|guard| guard.clone()) + .unwrap_or_default() +} + +fn normalize_api_base_url(value: &str) -> Result { + let normalized = value.trim().trim_end_matches('/').to_string(); + if normalized.is_empty() { + return Err("Cowork API base URL is required".to_string()); + } + Ok(normalized) +} diff --git a/frontend/src-tauri/src/cowork/agent_remote/cancel.rs b/frontend/src-tauri/src/cowork/agent_remote/cancel.rs new file mode 100644 index 000000000..e5f4c7635 --- /dev/null +++ b/frontend/src-tauri/src/cowork/agent_remote/cancel.rs @@ -0,0 +1,114 @@ +//! Fire-and-forget cancel dispatch for cowork remote runs. +//! +//! When the user presses Pause in the cowork chat box, the local +//! `stop_cowork_chat_session` command updates UI state immediately, but the +//! Python backend is still running the agent loop. This module opens a +//! short-lived, independent socket.io connection to Python, emits a single +//! `chat_message` event with `{command: "cancel"}`, and disconnects. +//! +//! Python's `CancelHandler` (see `src/ii_agent/realtime/handlers/cancel.py`) +//! then transitions the running task to ABORTING and sets a Redis cancel +//! flag; the agent's execution loop picks that up on its next poll and +//! throws `RunCancelledException`, which the backend translates into a +//! terminal socket event. The original `run_remote_agent_request` worker +//! (still blocked in `wait_for_remote_run_completion`) receives that event +//! via its OWN long-lived socket and exits cleanly via its existing +//! terminal branches. +//! +//! This is deliberately kept separate from `socket.rs::run_remote_agent_request` +//! because the cancel flow has very different semantics: no event loop, no +//! tool confirmation / continue-run, and errors are best-effort (logged and +//! dropped, never propagated — local stop must not fail because of a network +//! hiccup). +//! +//! ## Why no `join_session`? +//! +//! The Python `chat_message` handler +//! (`src/ii_agent/realtime/manager.py::chat_message`) resolves the session +//! directly from `request.session_uuid` via `_require_session` (DB lookup). +//! It does NOT require the socket to have previously called `join_session`. +//! +//! Emitting `join_session` from this short-lived cancel socket triggered a +//! race: the `join_session` handler performs `enter_room` + +//! `add_sid_to_session` async operations, and if Rust disconnected the +//! socket before those finished, python-socketio's internal sid→session map +//! raised `KeyError: 'Session not found'` during cleanup. Since `chat_message` +//! doesn't need the join, we skip it entirely and avoid the race. +//! +//! We also do NOT pass `session_uuid` in the connect-auth payload — the only +//! required auth field is `token`. Passing `session_uuid` would cause Python +//! to store it on the sid's session data, but since we never emit +//! `join_session`, it goes unused and could mislead future maintainers. + +use rust_socketio::{ClientBuilder as SocketClientBuilder, TransportType}; +use serde_json::json; +use std::thread; +use std::time::Duration; +use tauri::async_runtime::spawn_blocking; + +/// Grace period between emitting the cancel `chat_message` and disconnecting +/// the short-lived cancel socket. Gives Python's async event loop enough time +/// to fully dispatch the incoming event into `CancelHandler` before the socket +/// tears down. 250ms is plenty for an in-process event-loop dispatch and +/// cheap enough that the stop button still feels instant. +const CANCEL_EMIT_GRACE_MS: u64 = 250; + +/// Dispatch a `{command: "cancel"}` event to the Python backend for the given +/// remote session. Fire-and-forget: the returned `Result` is for logging only +/// and should never be used to block the caller's local stop flow. +pub async fn emit_cancel_signal( + api_base_url: String, + access_token: String, + remote_session_id: String, +) -> Result<(), String> { + spawn_blocking(move || -> Result<(), String> { + // Auth payload: ONLY token. See the module-level comment for why we + // deliberately omit session_uuid here. + let auth_payload = json!({ "token": access_token }); + + // Build a standalone socket.io client. We intentionally do NOT + // register any `on("chat_event", …)` listeners — we don't care about + // responses; the original run worker is still subscribed on ITS own + // long-lived socket and will receive the terminal event organically + // once Python marks the run as cancelled. + let socket = SocketClientBuilder::new(api_base_url.as_str()) + .transport_type(TransportType::Websocket) + .auth(auth_payload) + .connect() + .map_err(|error| { + format!("Cowork cancel dispatch: connect failed: {error}") + })?; + + // Emit the cancel command directly. Python's `chat_message` handler + // (`src/ii_agent/realtime/manager.py`) calls `_require_session` on + // `request.session_uuid`, so the session is resolved from the + // payload — no prior `join_session` emit is required. `CancelContent` + // extends `EmptyContent` with `extra="allow"`, so a bare + // `{command: "cancel"}` parses cleanly via the discriminated union. + socket + .emit( + "chat_message", + json!({ + "session_uuid": remote_session_id, + "content": { "command": "cancel" }, + }), + ) + .map_err(|error| { + format!("Cowork cancel dispatch: chat_message emit failed: {error}") + })?; + + // Give Python's event loop a moment to dispatch the cancel into + // CancelHandler before we tear down the socket. Without this sleep, + // a premature disconnect can race the server's async handler and + // cause spurious cleanup errors in python-socketio's internal state. + thread::sleep(Duration::from_millis(CANCEL_EMIT_GRACE_MS)); + + // Best-effort disconnect — ignore errors. We've already delivered the + // cancel signal. + let _ = socket.disconnect(); + + Ok(()) + }) + .await + .map_err(|error| format!("Cowork cancel dispatch worker join failed: {error}"))? +} diff --git a/frontend/src-tauri/src/cowork/agent_remote/desktop_dispatcher.rs b/frontend/src-tauri/src/cowork/agent_remote/desktop_dispatcher.rs new file mode 100644 index 000000000..192fd8079 --- /dev/null +++ b/frontend/src-tauri/src/cowork/agent_remote/desktop_dispatcher.rs @@ -0,0 +1,297 @@ +use crate::cowork::agent_presets::DesktopRuntimePreset; +use crate::cowork::chat::{ + emit_cowork_stream_event, CoworkChatEvent, CoworkChatRunStatus, CoworkChatRuntimeEvent, + CoworkChatScope, +}; +use crate::cowork::desktop_skills::DesktopSkill; +use crate::cowork::desktop_tools::{ + find_desktop_tool, DesktopExecutionScope, DesktopTool, DesktopToolContext, DesktopToolRuntime, +}; +use crate::cowork::session_gateway; +use crate::cowork::time_utils::now_iso; +use serde::Deserialize; +use serde_json::{json, Value}; +use tauri::AppHandle; + +#[derive(Debug, Deserialize)] +struct PausedToolDescriptor { + #[serde(default)] + tool_call_id: Option, + #[serde(default)] + tool_name: Option, + #[serde(default)] + tool_input: Option, + #[serde(default)] + requires_confirmation: Option, + #[serde(default)] + requires_user_input: Option, + #[serde(default)] + external_execution_required: Option, +} + +struct DesktopDispatcher<'a> { + app: &'a AppHandle, + local_session_id: &'a str, + preset: &'a DesktopRuntimePreset, + execution_scope: DesktopExecutionScope, + runtime: &'a mut DesktopToolRuntime, +} + +impl<'a> DesktopDispatcher<'a> { + fn new( + app: &'a AppHandle, + runtime_preset: &'a DesktopRuntimePreset, + local_session_id: &'a str, + runtime: &'a mut DesktopToolRuntime, + ) -> Result { + let execution_scope = (runtime_preset.load_execution_scope)(app, local_session_id)?; + Ok(Self { + app, + local_session_id, + preset: runtime_preset, + execution_scope, + runtime, + }) + } + + fn find_tool(&self, tool_name: &str) -> Result { + find_desktop_tool(&self.preset.tools, tool_name) + .cloned() + .ok_or_else(|| { + let available_skills = self.skill_names().join(", "); + if available_skills.is_empty() { + format!("Desktop tool is not available for this preset: {tool_name}") + } else { + format!( + "Desktop tool is not available for this preset: {tool_name}. Available desktop skills: {available_skills}" + ) + } + }) + } + + fn skill_names(&self) -> Vec<&str> { + self.preset.skills.iter().map(DesktopSkill::name).collect() + } + + fn tool_context(&mut self) -> DesktopToolContext<'_> { + DesktopToolContext::new( + self.app, + self.local_session_id, + &self.execution_scope, + self.runtime, + self.preset.refresh_scope, + ) + } +} + +pub fn auto_execute_external_tools( + app: &AppHandle, + scope: CoworkChatScope, + runtime_preset: Option<&DesktopRuntimePreset>, + local_session_id: &str, + content: &Value, + runtime: &mut DesktopToolRuntime, +) -> Result>, String> { + let Some(runtime_preset) = runtime_preset else { + return Ok(None); + }; + + let tools = parse_external_tools(content)?; + if tools.is_empty() { + return Ok(None); + } + + let mut dispatcher = DesktopDispatcher::new(app, runtime_preset, local_session_id, runtime)?; + emit_desktop_runtime_status(app, scope, local_session_id, CoworkChatRunStatus::Thinking); + let mut tool_results = Vec::new(); + let mut should_refresh_scope = false; + + for tool in tools { + let tool_name = tool.tool_name.clone().unwrap_or_default(); + let tool_input = tool + .tool_input + .clone() + .unwrap_or(Value::Object(Default::default())); + let tool_call_id = tool.tool_call_id.clone().unwrap_or_default(); + + let desktop_tool = dispatcher.find_tool(&tool_name)?; + emit_desktop_tool_call_event( + app, + scope, + local_session_id, + &tool_call_id, + &desktop_tool, + &tool_name, + &tool_input, + ); + let execution_result = { + let mut tool_context = dispatcher.tool_context(); + desktop_tool.execute(&mut tool_context, &tool_input) + }; + let is_error = execution_result.is_err(); + let llm_content = execution_result.unwrap_or_else(|error| error); + emit_desktop_tool_result_event( + app, + scope, + local_session_id, + &tool_call_id, + &desktop_tool, + &tool_name, + &tool_input, + &llm_content, + is_error, + ); + + if desktop_tool.refreshes_scope() && !is_error { + should_refresh_scope = true; + } + + tool_results.push(json!({ + "tool_call_id": tool_call_id, + "tool_name": tool_name, + "tool_input": tool_input, + "llm_content": llm_content, + "user_display_content": Value::Null, + "is_error": is_error, + "is_interrupted": false, + })); + } + + if should_refresh_scope { + let tool_context = dispatcher.tool_context(); + let _ = tool_context.refresh_scope_snapshot(); + } + + Ok(Some(tool_results)) +} + +fn parse_external_tools(content: &Value) -> Result, String> { + let tools_value = content + .get("tools") + .and_then(Value::as_array) + .ok_or_else(|| "Tool confirmation event did not include tools".to_string())?; + + let mut external_tools = Vec::new(); + for item in tools_value { + let tool: PausedToolDescriptor = serde_json::from_value(item.clone()) + .map_err(|error| format!("Failed to parse external tool request: {error}"))?; + if tool.requires_confirmation.unwrap_or(false) || tool.requires_user_input.unwrap_or(false) + { + return Ok(Vec::new()); + } + if tool.external_execution_required.unwrap_or(false) { + let has_tool_call_id = tool + .tool_call_id + .as_deref() + .map(str::trim) + .map(|value| !value.is_empty()) + .unwrap_or(false); + if !has_tool_call_id { + return Err( + "External desktop tool request did not include a tool_call_id".to_string(), + ); + } + external_tools.push(tool); + } + } + + Ok(external_tools) +} + +fn emit_desktop_runtime_status( + app: &AppHandle, + scope: CoworkChatScope, + local_session_id: &str, + status: CoworkChatRunStatus, +) { + let _ = session_gateway::persist_stream_status(app, scope, local_session_id, status); + let event = + session_gateway::build_status_updated_event(scope, local_session_id.to_string(), status); + let _ = emit_cowork_stream_event(app, &event); +} + +fn emit_desktop_tool_call_event( + app: &AppHandle, + scope: CoworkChatScope, + local_session_id: &str, + tool_call_id: &str, + tool: &DesktopTool, + tool_name: &str, + tool_input: &Value, +) { + emit_desktop_runtime_event( + app, + scope, + local_session_id, + "tool_call", + format!("desktop-tool-call:{tool_call_id}"), + json!({ + "tool_call_id": tool_call_id, + "tool_name": tool_name, + "tool_display_name": tool.display_name(), + "display_name": tool.display_name(), + "tool_input": tool_input, + "agent_name": "desktop", + }), + ); +} + +fn emit_desktop_tool_result_event( + app: &AppHandle, + scope: CoworkChatScope, + local_session_id: &str, + tool_call_id: &str, + tool: &DesktopTool, + tool_name: &str, + tool_input: &Value, + result: &str, + is_error: bool, +) { + emit_desktop_runtime_event( + app, + scope, + local_session_id, + "tool_result", + format!("desktop-tool-result:{tool_call_id}"), + json!({ + "tool_call_id": tool_call_id, + "tool_name": tool_name, + "tool_display_name": tool.display_name(), + "display_name": tool.display_name(), + "tool_input": tool_input, + "result": result, + "is_error": is_error, + "agent_name": "desktop", + }), + ); +} + +fn emit_desktop_runtime_event( + app: &AppHandle, + scope: CoworkChatScope, + local_session_id: &str, + runtime_event_type: &str, + runtime_event_id: String, + content: Value, +) { + let created_at = now_iso(); + let event = CoworkChatEvent::Runtime(CoworkChatRuntimeEvent { + event_type: "runtime.event".to_string(), + scope, + session_id: local_session_id.to_string(), + runtime_event_type: runtime_event_type.to_string(), + runtime_event_id: Some(runtime_event_id), + runtime_created_at: Some(created_at.clone()), + run_status: Some("running".to_string()), + emitted_at: created_at, + content, + }); + if let CoworkChatEvent::Runtime(runtime_event_payload) = &event { + let _ = session_gateway::persist_stream_runtime_event( + app, + runtime_event_payload, + Some(CoworkChatRunStatus::Thinking), + ); + } + let _ = emit_cowork_stream_event(app, &event); +} diff --git a/frontend/src-tauri/src/cowork/agent_remote/mapper.rs b/frontend/src-tauri/src/cowork/agent_remote/mapper.rs new file mode 100644 index 000000000..8df5bc8a8 --- /dev/null +++ b/frontend/src-tauri/src/cowork/agent_remote/mapper.rs @@ -0,0 +1,404 @@ +use super::prompt::extract_visible_user_content; +use super::socket::normalize_event_name; +use super::types::{RemoteSessionEventRecord, RemoteSessionFile}; +use crate::cowork::chat::{ + CoworkAgentRuntimeKind, CoworkChatFile, CoworkChatMessage, CoworkChatMessageRole, + CoworkChatRunStatus, CoworkChatRuntimeEvent, CoworkChatScope, +}; +use crate::cowork::runtime::CoworkRuntimeSessionSnapshot; +use crate::cowork::string_utils::normalize_optional_string; +use crate::cowork::time_utils::{generate_message_id, now_iso}; +use reqwest::StatusCode; +use serde_json::Value; + +pub fn map_remote_snapshot( + local_scope: CoworkChatScope, + local_session_id: &str, + runtime_session_id: &str, + updated_at: Option, + events: &[RemoteSessionEventRecord], + files: &[RemoteSessionFile], + run_status: Option<&str>, +) -> CoworkRuntimeSessionSnapshot { + let effective_updated_at = updated_at + .or_else(|| { + events + .iter() + .rev() + .find_map(|event| event.created_at.clone()) + }) + .unwrap_or_else(now_iso); + + CoworkRuntimeSessionSnapshot { + runtime_kind: CoworkAgentRuntimeKind::Remote, + runtime_session_id: runtime_session_id.to_string(), + updated_at: effective_updated_at, + messages: map_remote_messages(events), + files: collect_remote_files(files, ""), + run_status: map_remote_run_status(run_status), + runtime_events: map_runtime_events(local_scope, local_session_id, events, run_status), + } +} + +fn map_runtime_events( + local_scope: CoworkChatScope, + local_session_id: &str, + events: &[RemoteSessionEventRecord], + run_status: Option<&str>, +) -> Vec { + events + .iter() + .map(|event| CoworkChatRuntimeEvent { + event_type: "runtime.event".to_string(), + scope: local_scope, + session_id: local_session_id.to_string(), + runtime_event_type: normalize_event_name(&event.event_type), + runtime_event_id: Some(event.id.clone()), + runtime_created_at: event.created_at.clone(), + run_status: run_status.map(ToString::to_string), + emitted_at: event.created_at.clone().unwrap_or_else(now_iso), + content: event.content.clone(), + }) + .collect() +} + +pub fn extract_error_message(content: &Value) -> String { + content + .get("message") + .and_then(Value::as_str) + .or_else(|| content.get("error").and_then(Value::as_str)) + .or_else(|| content.get("detail").and_then(Value::as_str)) + .unwrap_or_default() + .to_string() +} + +pub fn build_backend_error_message(status: StatusCode, body: &str, auth_required: &str) -> String { + match status { + StatusCode::UNAUTHORIZED => auth_required.to_string(), + StatusCode::PAYMENT_REQUIRED => { + "Current user does not have enough credits to run Cowork AI.".to_string() + } + StatusCode::NOT_FOUND => extract_error_detail(body) + .unwrap_or_else(|| format!("Cowork AI backend request failed with status {status}")), + _ => extract_error_detail(body) + .unwrap_or_else(|| format!("Cowork AI backend request failed with status {status}")), + } +} + +pub fn is_terminal_run_status(value: &str) -> bool { + matches!(value, "completed" | "aborted" | "failed" | "error") +} + +pub fn is_waiting_run_status(value: &str) -> bool { + matches!(value, "paused" | "waiting_for_input") +} + +fn map_remote_messages(events: &[RemoteSessionEventRecord]) -> Vec { + let mut mapped_messages = Vec::new(); + let mut thinking_buffer = String::new(); + let mut response_buffer = String::new(); + let mut thinking_started_at: Option = None; + let mut response_started_at: Option = None; + let mut thinking_anchor: Option = None; + let mut response_anchor: Option = None; + + let flush_thinking = |messages: &mut Vec, + buffer: &mut String, + started_at: &mut Option, + anchor: &mut Option| { + if let Some(content) = normalize_optional_string(buffer) { + messages.push(CoworkChatMessage { + id: anchor + .as_deref() + .map(|value| build_transcript_message_id("thinking", value)) + .unwrap_or_else(generate_message_id), + role: CoworkChatMessageRole::Assistant, + content, + created_at: started_at.clone().unwrap_or_else(now_iso), + is_think_message: Some(true), + }); + } + buffer.clear(); + *started_at = None; + *anchor = None; + }; + + let flush_response = |messages: &mut Vec, + buffer: &mut String, + started_at: &mut Option, + anchor: &mut Option| { + if let Some(content) = normalize_optional_string(buffer) { + messages.push(CoworkChatMessage { + id: anchor + .as_deref() + .map(|value| build_transcript_message_id("response", value)) + .unwrap_or_else(generate_message_id), + role: CoworkChatMessageRole::Assistant, + content, + created_at: started_at.clone().unwrap_or_else(now_iso), + is_think_message: None, + }); + } + buffer.clear(); + *started_at = None; + *anchor = None; + }; + + for event in events { + let normalized_type = normalize_event_name(&event.event_type); + match normalized_type.as_str() { + "user_message" | "session_user_message" => { + flush_thinking( + &mut mapped_messages, + &mut thinking_buffer, + &mut thinking_started_at, + &mut thinking_anchor, + ); + flush_response( + &mut mapped_messages, + &mut response_buffer, + &mut response_started_at, + &mut response_anchor, + ); + let text = extract_event_text(&event.content); + let visible_text = extract_visible_user_content(&text); + if !visible_text.is_empty() { + mapped_messages.push(CoworkChatMessage { + id: event.id.clone(), + role: CoworkChatMessageRole::User, + content: visible_text, + created_at: event.created_at.clone().unwrap_or_else(now_iso), + is_think_message: None, + }); + } + } + "agent_thinking_delta" => { + if thinking_started_at.is_none() { + thinking_started_at = event.created_at.clone(); + thinking_anchor = Some(event.id.clone()); + } + thinking_buffer.push_str(&extract_event_text(&event.content)); + } + "agent_thinking" => { + if let Some(text) = normalize_optional_string(&extract_event_text(&event.content)) { + thinking_buffer.clear(); + thinking_buffer.push_str(&text); + thinking_started_at = event.created_at.clone(); + thinking_anchor = Some(event.id.clone()); + } + flush_thinking( + &mut mapped_messages, + &mut thinking_buffer, + &mut thinking_started_at, + &mut thinking_anchor, + ); + } + "agent_response_delta" => { + if response_started_at.is_none() { + response_started_at = event.created_at.clone(); + response_anchor = Some(event.id.clone()); + } + response_buffer.push_str(&extract_event_text(&event.content)); + } + "agent_response" => { + if let Some(text) = normalize_optional_string(&extract_event_text(&event.content)) { + response_buffer.clear(); + response_buffer.push_str(&text); + response_started_at = event.created_at.clone(); + response_anchor = Some(event.id.clone()); + } + flush_response( + &mut mapped_messages, + &mut response_buffer, + &mut response_started_at, + &mut response_anchor, + ); + } + "complete" | "sub_agent_complete" => { + if response_buffer.is_empty() { + response_buffer.push_str(&extract_event_text(&event.content)); + response_started_at = event.created_at.clone(); + response_anchor = Some(event.id.clone()); + } + flush_response( + &mut mapped_messages, + &mut response_buffer, + &mut response_started_at, + &mut response_anchor, + ); + } + "error" => { + flush_thinking( + &mut mapped_messages, + &mut thinking_buffer, + &mut thinking_started_at, + &mut thinking_anchor, + ); + flush_response( + &mut mapped_messages, + &mut response_buffer, + &mut response_started_at, + &mut response_anchor, + ); + let text = extract_error_message(&event.content); + if !text.is_empty() { + mapped_messages.push(CoworkChatMessage { + id: event.id.clone(), + role: CoworkChatMessageRole::Assistant, + content: text, + created_at: event.created_at.clone().unwrap_or_else(now_iso), + is_think_message: None, + }); + } + } + _ => {} + } + } + + flush_thinking( + &mut mapped_messages, + &mut thinking_buffer, + &mut thinking_started_at, + &mut thinking_anchor, + ); + flush_response( + &mut mapped_messages, + &mut response_buffer, + &mut response_started_at, + &mut response_anchor, + ); + + mapped_messages +} + +#[cfg(test)] +mod tests { + use super::map_remote_snapshot; + use crate::cowork::chat::CoworkChatScope; + use serde_json::json; + + use super::super::types::RemoteSessionEventRecord; + + #[test] + fn snapshot_includes_user_messages_from_session_user_message_events() { + let snapshot = map_remote_snapshot( + CoworkChatScope::Homepage, + "local-session", + "remote-session", + Some("2026-04-06T00:00:00Z".to_string()), + &[RemoteSessionEventRecord { + id: "evt-1".to_string(), + event_type: "session.user_message".to_string(), + content: json!({ + "text": "Please folder these files" + }), + created_at: Some("2026-04-06T00:00:00Z".to_string()), + }], + &[], + Some("completed"), + ); + + assert_eq!(snapshot.messages.len(), 1); + assert_eq!(snapshot.messages[0].role, crate::cowork::chat::CoworkChatMessageRole::User); + assert_eq!(snapshot.messages[0].content, "Please folder these files"); + } + + #[test] + fn snapshot_maps_reasoning_events_to_thinking_messages() { + let snapshot = map_remote_snapshot( + CoworkChatScope::Homepage, + "local-session", + "remote-session", + Some("2026-04-06T00:00:00Z".to_string()), + &[RemoteSessionEventRecord { + id: "evt-2".to_string(), + event_type: "agent.reasoning.delta".to_string(), + content: json!({ + "text": "Inspecting files..." + }), + created_at: Some("2026-04-06T00:00:00Z".to_string()), + }], + &[], + Some("running"), + ); + + assert_eq!(snapshot.messages.len(), 1); + assert_eq!( + snapshot.messages[0].role, + crate::cowork::chat::CoworkChatMessageRole::Assistant + ); + assert_eq!(snapshot.messages[0].content, "Inspecting files..."); + assert_eq!(snapshot.messages[0].is_think_message, Some(true)); + } +} + +fn collect_remote_files( + files: &[RemoteSessionFile], + fallback_created_at: &str, +) -> Vec { + let created_at = normalize_optional_string(fallback_created_at).unwrap_or_else(now_iso); + files + .iter() + .map(|file| CoworkChatFile { + id: file.id.clone(), + file_name: normalize_optional_string(&file.name) + .or_else(|| { + file.url + .as_ref() + .and_then(|url| normalize_optional_string(url)) + }) + .unwrap_or_else(|| file.id.clone()), + file_size: file.size, + content_type: normalize_optional_string(&file.content_type).unwrap_or_default(), + created_at: created_at.clone(), + }) + .collect() +} + +fn extract_event_text(content: &Value) -> String { + content + .get("text") + .and_then(Value::as_str) + .or_else(|| content.get("message").and_then(Value::as_str)) + .or_else(|| content.get("content").and_then(Value::as_str)) + .or_else(|| content.get("delta").and_then(Value::as_str)) + .unwrap_or_default() + .to_string() +} + +pub fn map_remote_run_status(run_status: Option<&str>) -> CoworkChatRunStatus { + match run_status { + Some("running") => CoworkChatRunStatus::Thinking, + Some("paused") | Some("waiting_for_input") => CoworkChatRunStatus::WaitingForInput, + Some("aborted") | Some("cancelled") | Some("failed") | Some("error") => { + CoworkChatRunStatus::Stopped + } + _ => CoworkChatRunStatus::Completed, + } +} + +fn build_transcript_message_id(kind: &str, anchor: &str) -> String { + let normalized_anchor = anchor.split_whitespace().collect::>().join("-"); + format!("cowork-transcript:{kind}:{normalized_anchor}") +} + +fn extract_error_detail(body: &str) -> Option { + let parsed = serde_json::from_str::(body).ok()?; + + parsed + .get("detail") + .and_then(Value::as_str) + .map(ToString::to_string) + .or_else(|| { + parsed + .get("message") + .and_then(Value::as_str) + .map(ToString::to_string) + }) + .or_else(|| { + parsed + .get("error") + .and_then(Value::as_str) + .map(ToString::to_string) + }) +} diff --git a/frontend/src-tauri/src/cowork/agent_remote/mod.rs b/frontend/src-tauri/src/cowork/agent_remote/mod.rs new file mode 100644 index 000000000..33f7b336f --- /dev/null +++ b/frontend/src-tauri/src/cowork/agent_remote/mod.rs @@ -0,0 +1,19 @@ +pub mod auth; +pub mod cancel; +mod desktop_dispatcher; +mod mapper; +mod payload; +mod prompt; +pub mod service; +mod session_api; +mod socket; +mod types; + +pub(super) use payload::build_remote_command; +pub(super) use prompt::build_remote_content; +pub(super) use session_api::{ + ensure_remote_session_uses_v1, fetch_remote_session_info, fetch_remote_session_snapshot, + resolve_remote_model_selection, should_recreate_remote_session, +}; +pub(super) use socket::run_remote_agent_request; +pub(super) use types::{V1_BACKEND_REQUIRED_MESSAGE, V1_REQUIRED_MESSAGE}; diff --git a/frontend/src-tauri/src/cowork/agent_remote/payload.rs b/frontend/src-tauri/src/cowork/agent_remote/payload.rs new file mode 100644 index 000000000..a4769c81e --- /dev/null +++ b/frontend/src-tauri/src/cowork/agent_remote/payload.rs @@ -0,0 +1,355 @@ +use super::types::{ + RemoteAgentCommandContent, RemoteAgentToolArgs, RemoteModelSelection, + RemoteRequestedCapabilities, REMOTE_AGENT_TYPE, REMOTE_BUILD_MODE, +}; +use crate::cowork::agent_presets::shared::{ + DesktopCapabilities, DesktopSkillCapability, DesktopToolCapability, +}; +use crate::cowork::chat::{ + CoworkAgentOverrides, CoworkChatToolSettings, CoworkGitHubRepositoryContext, +}; +use crate::cowork::string_utils::normalize_optional_string; +use serde_json::Value; + +pub fn build_remote_command( + model_id: String, + model_selection: RemoteModelSelection, + text: String, + resume: bool, + tools: Option<&CoworkChatToolSettings>, + metadata: Option, + desktop_capabilities: Option, + github_repository: Option, + agent_overrides: Option<&CoworkAgentOverrides>, +) -> Result { + serde_json::to_value(RemoteAgentCommandContent { + model_id, + provider: model_selection.provider, + source: model_selection.source, + agent_type: REMOTE_AGENT_TYPE, + tool_args: map_remote_tool_args(tools), + thinking_tokens: 0, + text, + resume, + files: Vec::new(), + metadata, + requested_capabilities: build_requested_capabilities(agent_overrides, desktop_capabilities), + github_repository, + build_mode: REMOTE_BUILD_MODE, + system_prompt: build_system_prompt(agent_overrides), + }) + .map_err(|error| format!("Failed to encode Cowork agent command: {error}")) +} + +fn map_remote_tool_args(tools: Option<&CoworkChatToolSettings>) -> RemoteAgentToolArgs { + let media_generation = tools + .map(|settings| { + settings.generate_image.unwrap_or(false) || settings.generate_video.unwrap_or(false) + }) + .unwrap_or(false); + + RemoteAgentToolArgs { + task_agent: false, + deep_research: false, + pdf: true, + media_generation, + audio_generation: false, + browser: false, + enable_reviewer: false, + design_document: false, + codex_tools: false, + claude_code: false, + } +} + +fn build_system_prompt(agent_overrides: Option<&CoworkAgentOverrides>) -> Option { + agent_overrides + .and_then(|overrides| overrides.system_prompt.clone()) + .as_deref() + .and_then(normalize_optional_string) +} + +fn build_requested_capabilities( + agent_overrides: Option<&CoworkAgentOverrides>, + desktop_capabilities: Option, +) -> Option { + let skill_names = + sanitize_name_list(agent_overrides.and_then(|overrides| overrides.skill_names.as_ref())); + let tool_names = + sanitize_name_list(agent_overrides.and_then(|overrides| overrides.tool_names.as_ref())); + + let (client_tools, core_tools) = + split_client_and_core_tools(desktop_capabilities.as_ref(), tool_names.as_ref()); + let (client_skills, core_skills) = + split_client_and_core_skills(desktop_capabilities.as_ref(), skill_names.as_ref()); + + if client_tools.is_none() + && client_skills.is_none() + && core_tools.is_none() + && core_skills.is_none() + { + return None; + } + + Some(RemoteRequestedCapabilities { + client_tools, + client_skills, + core_tools, + core_skills, + connector: None, + }) +} + +fn sanitize_name_list(values: Option<&Vec>) -> Option> { + let mut normalized = Vec::new(); + if let Some(items) = values { + for item in items { + if let Some(value) = normalize_optional_string(item) { + if !normalized.contains(&value) { + normalized.push(value); + } + } + } + } + + if normalized.is_empty() { + None + } else { + Some(normalized) + } +} + +fn split_client_and_core_tools( + desktop_capabilities: Option<&DesktopCapabilities>, + selected_tool_names: Option<&Vec>, +) -> (Option>, Option>) { + let Some(desktop_capabilities) = desktop_capabilities else { + return (None, selected_tool_names.cloned()); + }; + + let mut client_tools = Vec::new(); + let mut core_tools = Vec::new(); + + match selected_tool_names { + Some(selected_names) => { + for selected_name in selected_names { + if let Some(capability) = + find_matching_client_tool(&desktop_capabilities.tools, selected_name) + { + if !client_tools + .iter() + .any(|existing: &DesktopToolCapability| existing.name == capability.name) + { + client_tools.push(capability.clone()); + } + } else if !core_tools.iter().any(|existing| existing == selected_name) { + core_tools.push(selected_name.clone()); + } + } + } + None => client_tools.extend(desktop_capabilities.tools.iter().cloned()), + } + + ( + if client_tools.is_empty() { + None + } else { + Some(client_tools) + }, + if core_tools.is_empty() { + None + } else { + Some(core_tools) + }, + ) +} + +fn split_client_and_core_skills( + desktop_capabilities: Option<&DesktopCapabilities>, + selected_skill_names: Option<&Vec>, +) -> (Option>, Option>) { + let Some(desktop_capabilities) = desktop_capabilities else { + return (None, selected_skill_names.cloned()); + }; + + let mut client_skills = Vec::new(); + let mut core_skills = Vec::new(); + + match selected_skill_names { + Some(selected_names) => { + for selected_name in selected_names { + if let Some(capability) = + find_matching_client_skill(&desktop_capabilities.skills, selected_name) + { + if !client_skills + .iter() + .any(|existing: &DesktopSkillCapability| existing.name == capability.name) + { + client_skills.push(capability.clone()); + } + } else if !core_skills.iter().any(|existing| existing == selected_name) { + core_skills.push(selected_name.clone()); + } + } + } + None => client_skills.extend(desktop_capabilities.skills.iter().cloned()), + } + + ( + if client_skills.is_empty() { + None + } else { + Some(client_skills) + }, + if core_skills.is_empty() { + None + } else { + Some(core_skills) + }, + ) +} + +fn find_matching_client_tool<'a>( + tools: &'a [DesktopToolCapability], + selected_name: &str, +) -> Option<&'a DesktopToolCapability> { + let normalized = selected_name.trim(); + if normalized.is_empty() { + return None; + } + + tools.iter().find(|tool| { + tool.name.eq_ignore_ascii_case(normalized) + || tool + .aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(normalized)) + }) +} + +fn find_matching_client_skill<'a>( + skills: &'a [DesktopSkillCapability], + selected_name: &str, +) -> Option<&'a DesktopSkillCapability> { + let normalized = selected_name.trim(); + if normalized.is_empty() { + return None; + } + + skills + .iter() + .find(|skill| skill.name.eq_ignore_ascii_case(normalized)) +} + +#[cfg(test)] +mod tests { + use super::{ + build_requested_capabilities, DesktopCapabilities, DesktopSkillCapability, + DesktopToolCapability, + }; + use crate::cowork::chat::CoworkAgentOverrides; + use serde_json::json; + + fn sample_desktop_capabilities() -> DesktopCapabilities { + DesktopCapabilities { + tools: vec![DesktopToolCapability { + name: "Read".to_string(), + aliases: vec!["read_file".to_string()], + display_name: "Read".to_string(), + description: "Read a file from desktop scope.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "path": { "type": "string" } + }, + "required": ["path"] + }), + }], + skills: vec![DesktopSkillCapability { + name: "pdf".to_string(), + description: "Process PDFs on the desktop runtime.".to_string(), + }], + } + } + + #[test] + fn builds_client_capabilities_from_desktop_capability_catalog() { + let requested = build_requested_capabilities( + Some(&CoworkAgentOverrides { + system_prompt: None, + tool_names: Some(vec!["Read".to_string()]), + skill_names: Some(vec!["pdf".to_string()]), + runtime_options: None, + }), + Some(sample_desktop_capabilities()), + ) + .expect("requested capabilities"); + + assert_eq!(requested.core_tools, None); + assert_eq!(requested.core_skills, None); + assert_eq!( + requested + .client_tools + .expect("client tools") + .into_iter() + .map(|tool| tool.name) + .collect::>(), + vec!["Read".to_string()] + ); + assert_eq!( + requested + .client_skills + .expect("client skills") + .into_iter() + .map(|skill| skill.name) + .collect::>(), + vec!["pdf".to_string()] + ); + } + + #[test] + fn keeps_non_desktop_overrides_as_core_capabilities() { + let requested = build_requested_capabilities( + Some(&CoworkAgentOverrides { + system_prompt: None, + tool_names: Some(vec!["web_search".to_string()]), + skill_names: Some(vec!["writer".to_string()]), + runtime_options: None, + }), + Some(sample_desktop_capabilities()), + ) + .expect("requested capabilities"); + + assert_eq!(requested.client_tools, None); + assert_eq!(requested.client_skills, None); + assert_eq!(requested.core_tools, Some(vec!["web_search".to_string()])); + assert_eq!(requested.core_skills, Some(vec!["writer".to_string()])); + } + + #[test] + fn includes_all_desktop_capabilities_when_no_subset_is_requested() { + let requested = build_requested_capabilities(None, Some(sample_desktop_capabilities())) + .expect("requested capabilities"); + + assert_eq!( + requested + .client_tools + .expect("client tools") + .into_iter() + .map(|tool| tool.name) + .collect::>(), + vec!["Read".to_string()] + ); + assert_eq!( + requested + .client_skills + .expect("client skills") + .into_iter() + .map(|skill| skill.name) + .collect::>(), + vec!["pdf".to_string()] + ); + assert_eq!(requested.core_tools, None); + assert_eq!(requested.core_skills, None); + } +} diff --git a/frontend/src-tauri/src/cowork/agent_remote/prompt.rs b/frontend/src-tauri/src/cowork/agent_remote/prompt.rs new file mode 100644 index 000000000..ef2ed0507 --- /dev/null +++ b/frontend/src-tauri/src/cowork/agent_remote/prompt.rs @@ -0,0 +1,17 @@ +use crate::cowork::string_utils::normalize_optional_string; + +const USER_REQUEST_MARKER: &str = "\n\n[User request]\n"; + +pub fn build_remote_content(content: &str, prompt_context: Option<&str>) -> String { + match prompt_context.and_then(normalize_optional_string) { + Some(context) => format!("{context}{USER_REQUEST_MARKER}{content}"), + None => content.to_string(), + } +} + +pub fn extract_visible_user_content(content: &str) -> String { + content + .split_once(USER_REQUEST_MARKER) + .map(|(_, user_request)| user_request.trim().to_string()) + .unwrap_or_else(|| content.trim().to_string()) +} diff --git a/frontend/src-tauri/src/cowork/agent_remote/service.rs b/frontend/src-tauri/src/cowork/agent_remote/service.rs new file mode 100644 index 000000000..d4baa723c --- /dev/null +++ b/frontend/src-tauri/src/cowork/agent_remote/service.rs @@ -0,0 +1,274 @@ +use super::auth::{self, RemoteAuthState}; +use super::{ + build_remote_command, build_remote_content, ensure_remote_session_uses_v1, + fetch_remote_session_info, fetch_remote_session_snapshot, resolve_remote_model_selection, + run_remote_agent_request, should_recreate_remote_session, V1_BACKEND_REQUIRED_MESSAGE, + V1_REQUIRED_MESSAGE, +}; +use crate::cowork::agent_presets; +use crate::cowork::chat::{ + CoworkAgentRuntimeKind, CoworkChatMessageRole, CoworkChatSendMessageRequest, + CoworkChatSendMessageResponse, +}; +use crate::cowork::session_gateway; +use crate::cowork::time_utils::now_iso; +use reqwest::Client; +use serde_json::{json, Value}; +use tauri::{AppHandle, State}; + +pub async fn send_remote_chat_message( + app: AppHandle, + state: State<'_, RemoteAuthState>, + request: CoworkChatSendMessageRequest, +) -> Result { + let normalized_content = request.content.trim().to_string(); + if normalized_content.is_empty() { + return Err("Cowork message content is required".to_string()); + } + + let normalized_model_id = request.model_id.trim().to_string(); + if normalized_model_id.is_empty() { + return Err("Cowork model_id is required".to_string()); + } + + let (mut local_session, is_new_session) = + session_gateway::load_or_create_local_session(&app, &request)?; + ensure_remote_runtime_session(local_session.base())?; + + let user_message = session_gateway::build_local_message( + CoworkChatMessageRole::User, + normalized_content.clone(), + false, + None, + ); + + { + let base = local_session.base_mut(); + base.messages.push(user_message); + base.updated_at = now_iso(); + base.run_status = crate::cowork::chat::CoworkChatRunStatus::Thinking; + if base.preview.trim().is_empty() { + base.preview = normalized_content.clone(); + } + } + + local_session = session_gateway::persist_local_session(&app, local_session)?; + session_gateway::emit_local_session_started(&app, &local_session, is_new_session); + + let auth_context = auth::get_auth_context(&state); + let access_token = match auth_context.access_token { + Some(token) => token, + None => { + local_session = persist_runtime_error_with_latest( + &app, + local_session, + auth::AUTH_REQUIRED_MESSAGE.to_string(), + )?; + session_gateway::emit_local_terminal_state(&app, &local_session, true); + return Ok(session_gateway::build_send_response( + &local_session, + is_new_session, + )); + } + }; + let api_base_url = auth_context + .api_base_url + .ok_or_else(|| "Cowork API base URL is not configured".to_string())?; + + let client = Client::new(); + let mut active_runtime_session_id = local_session.base().runtime_session_id.clone(); + if let Some(runtime_session_id) = active_runtime_session_id.clone() { + match fetch_remote_session_info(&client, &api_base_url, &access_token, &runtime_session_id) + .await + { + Ok(session_info) => { + if ensure_remote_session_uses_v1(&session_info).is_err() { + session_gateway::clear_runtime_binding(&mut local_session); + local_session = session_gateway::persist_local_session(&app, local_session)?; + active_runtime_session_id = None; + } + } + Err(error_message) => { + if should_recreate_remote_session(&error_message) { + session_gateway::clear_runtime_binding(&mut local_session); + local_session = session_gateway::persist_local_session(&app, local_session)?; + session_gateway::emit_session_updated(&app, &local_session); + active_runtime_session_id = None; + } else { + local_session = + persist_runtime_error_with_latest(&app, local_session, error_message)?; + session_gateway::emit_local_terminal_state(&app, &local_session, true); + return Ok(session_gateway::build_send_response( + &local_session, + is_new_session, + )); + } + } + } + } + + let should_resume_remote = active_runtime_session_id.is_some(); + let model_selection = match resolve_remote_model_selection( + &client, + &api_base_url, + &access_token, + &normalized_model_id, + ) + .await + { + Ok(selection) => selection, + Err(error_message) => { + local_session = persist_runtime_error_with_latest(&app, local_session, error_message)?; + session_gateway::emit_local_terminal_state(&app, &local_session, true); + return Ok(session_gateway::build_send_response( + &local_session, + is_new_session, + )); + } + }; + + let prompt_context = + session_gateway::resolve_prompt_context(&local_session, request.prompt_context.clone()); + let resolved_agent_overrides = agent_presets::resolve_agent_overrides( + local_session.base().scope, + prompt_context.as_deref(), + request.tools.as_ref(), + request.agent_overrides.clone(), + ); + let outbound_content = build_remote_content(&normalized_content, prompt_context.as_deref()); + let runtime_metadata = build_remote_runtime_metadata(&local_session); + let resolved_desktop_preset = agent_presets::resolve_desktop_preset(local_session.base().scope); + let runtime_desktop_capabilities = resolved_desktop_preset + .as_ref() + .map(|preset| preset.capabilities.clone()); + + let remote_command = match build_remote_command( + normalized_model_id, + model_selection, + outbound_content, + should_resume_remote, + request.tools.as_ref(), + runtime_metadata, + runtime_desktop_capabilities, + request.github_repository.clone(), + Some(&resolved_agent_overrides), + ) { + Ok(command) => command, + Err(error_message) => { + local_session = persist_runtime_error_with_latest(&app, local_session, error_message)?; + session_gateway::emit_local_terminal_state(&app, &local_session, true); + return Ok(session_gateway::build_send_response( + &local_session, + is_new_session, + )); + } + }; + + let run_outcome = match run_remote_agent_request( + &api_base_url, + &access_token, + active_runtime_session_id, + remote_command, + app.clone(), + local_session.base().scope, + resolved_desktop_preset.map(|preset| preset.runtime), + local_session.base().id.clone(), + ) + .await + { + Ok(outcome) => outcome, + Err(error_message) => { + local_session = persist_runtime_error_with_latest(&app, local_session, error_message)?; + session_gateway::emit_local_terminal_state(&app, &local_session, true); + return Ok(session_gateway::build_send_response( + &local_session, + is_new_session, + )); + } + }; + + local_session = reload_latest_local_session(&app, &local_session); + + let runtime_snapshot = match fetch_remote_session_snapshot( + &client, + &api_base_url, + &access_token, + local_session.base().scope, + &local_session.base().id, + &run_outcome.runtime_session_id, + ) + .await + { + Ok(snapshot) => snapshot, + Err(error_message) => { + let display_error = if !should_resume_remote && error_message == V1_REQUIRED_MESSAGE { + V1_BACKEND_REQUIRED_MESSAGE.to_string() + } else { + error_message + }; + local_session = persist_runtime_error_with_latest(&app, local_session, display_error)?; + session_gateway::emit_local_terminal_state(&app, &local_session, true); + return Ok(session_gateway::build_send_response( + &local_session, + is_new_session, + )); + } + }; + + local_session = reload_latest_local_session(&app, &local_session); + session_gateway::apply_runtime_session_snapshot(&mut local_session, runtime_snapshot); + session_gateway::sync_folder_result_tree(&mut local_session)?; + local_session = session_gateway::persist_local_session(&app, local_session)?; + session_gateway::emit_local_terminal_state(&app, &local_session, false); + + Ok(session_gateway::build_send_response( + &local_session, + is_new_session, + )) +} + +fn ensure_remote_runtime_session( + session: &crate::cowork::chat::CoworkChatSessionDetail, +) -> Result<(), String> { + match session.runtime_kind { + Some(CoworkAgentRuntimeKind::Remote) | None => Ok(()), + Some(CoworkAgentRuntimeKind::Local) => Err( + "This Cowork session is already bound to the local runtime and cannot be sent through the remote runtime." + .to_string(), + ), + } +} + +fn reload_latest_local_session( + app: &AppHandle, + session: &session_gateway::LocalCoworkSession, +) -> session_gateway::LocalCoworkSession { + session_gateway::load_local_session(app, session.base().scope, &session.base().id) + .unwrap_or_else(|_| session.clone()) +} + +fn persist_runtime_error_with_latest( + app: &AppHandle, + session: session_gateway::LocalCoworkSession, + error_message: String, +) -> Result { + let latest_session = reload_latest_local_session(app, &session); + session_gateway::persist_runtime_error(app, latest_session, error_message) +} + +fn build_remote_runtime_metadata(session: &session_gateway::LocalCoworkSession) -> Option { + match session { + session_gateway::LocalCoworkSession::Homepage(_) => None, + session_gateway::LocalCoworkSession::Folder(detail) => Some(json!({ + "cowork": { + "scope": "intelligent-folder", + "execution_context": "desktop", + "tool_runtime": "desktop_builtin", + "tool_binding_mode": "desktop_only", + "local_session_id": detail.base.id.clone(), + "source_root": detail.folder_tree_pair.source_root.clone(), + "result_root": detail.folder_tree_pair.result_root.clone(), + } + })), + } +} diff --git a/frontend/src-tauri/src/cowork/agent_remote/session_api.rs b/frontend/src-tauri/src/cowork/agent_remote/session_api.rs new file mode 100644 index 000000000..40394f71d --- /dev/null +++ b/frontend/src-tauri/src/cowork/agent_remote/session_api.rs @@ -0,0 +1,239 @@ +use super::mapper::{build_backend_error_message, map_remote_snapshot}; +use super::types::{ + RemoteAvailableModelsResponse, RemoteModelSelection, RemoteSessionEventsResponse, + RemoteSessionFile, RemoteSessionInfo, RemoteSessionState, V1_REQUIRED_MESSAGE, +}; +use crate::cowork::agent_remote::auth::AUTH_REQUIRED_MESSAGE; +use crate::cowork::chat::CoworkChatScope; +use crate::cowork::runtime::CoworkRuntimeSessionSnapshot; +use crate::cowork::string_utils::normalize_optional_string; +use reqwest::{header::AUTHORIZATION, Client}; + +pub async fn fetch_remote_session_info( + client: &Client, + api_base_url: &str, + access_token: &str, + remote_session_id: &str, +) -> Result { + let response = client + .get(format!("{api_base_url}/v1/sessions/{remote_session_id}")) + .header(AUTHORIZATION, format!("Bearer {access_token}")) + .send() + .await + .map_err(|error| format!("Failed to load Cowork agent session info: {error}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "Unknown backend error".to_string()); + return Err(build_backend_error_message( + status, + &body, + AUTH_REQUIRED_MESSAGE, + )); + } + + response + .json::() + .await + .map_err(|error| format!("Failed to decode Cowork agent session info: {error}")) +} + +pub fn ensure_remote_session_uses_v1(session_info: &RemoteSessionInfo) -> Result<(), String> { + match session_info.api_version.as_deref() { + Some("v1") | None => Ok(()), + Some(_) => Err(V1_REQUIRED_MESSAGE.to_string()), + } +} + +pub fn should_recreate_remote_session(error_message: &str) -> bool { + error_message == V1_REQUIRED_MESSAGE + || error_message.contains("status 404") + || error_message.contains("not found or access denied") +} + +pub async fn resolve_remote_model_selection( + client: &Client, + api_base_url: &str, + access_token: &str, + model_id: &str, +) -> Result { + let response = client + .get(format!("{api_base_url}/v1/user-settings/models")) + .header(AUTHORIZATION, format!("Bearer {access_token}")) + .send() + .await + .map_err(|error| format!("Failed to load available AI models for Cowork: {error}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "Unknown backend error".to_string()); + return Err(build_backend_error_message( + status, + &body, + AUTH_REQUIRED_MESSAGE, + )); + } + + let payload = response + .json::() + .await + .map_err(|error| format!("Failed to decode available AI models for Cowork: {error}"))?; + + let model = payload + .models + .into_iter() + .find(|model| model.id == model_id) + .ok_or_else(|| { + format!("The selected AI model `{model_id}` is not available for the current user.") + })?; + + let source = model + .source + .and_then(|value| normalize_optional_string(&value)) + .ok_or_else(|| { + format!("The selected AI model `{model_id}` does not provide a valid source.") + })?; + + if source != "user" && source != "system" { + return Err(format!( + "The selected AI model `{model_id}` returned an unsupported source `{source}`." + )); + } + + let provider = model + .provider + .and_then(|value| normalize_optional_string(&value)) + .ok_or_else(|| { + format!("The selected AI model `{model_id}` does not provide a valid provider.") + })?; + + let provider_lower = provider.to_lowercase(); + if !matches!( + provider_lower.as_str(), + "openai" | "anthropic" | "gemini" | "google" | "cerebras" | "custom" + ) { + return Err(format!( + "The selected AI model `{model_id}` returned an unsupported provider `{provider}`." + )); + } + + Ok(RemoteModelSelection { + source, + provider: provider_lower, + }) +} + +pub async fn fetch_remote_session_snapshot( + client: &Client, + api_base_url: &str, + access_token: &str, + local_scope: CoworkChatScope, + local_session_id: &str, + runtime_session_id: &str, +) -> Result { + let remote_session = + fetch_remote_session_state(client, api_base_url, access_token, runtime_session_id).await?; + + Ok(map_remote_snapshot( + local_scope, + local_session_id, + runtime_session_id, + remote_session.session_info.updated_at.clone(), + &remote_session.event_response.events, + &remote_session.files, + remote_session.event_response.run_status.as_deref(), + )) +} + +async fn fetch_remote_session_state( + client: &Client, + api_base_url: &str, + access_token: &str, + remote_session_id: &str, +) -> Result { + let session_info = + fetch_remote_session_info(client, api_base_url, access_token, remote_session_id).await?; + ensure_remote_session_uses_v1(&session_info)?; + let event_response = + fetch_remote_session_events(client, api_base_url, access_token, remote_session_id).await?; + let files = + fetch_remote_session_files(client, api_base_url, access_token, remote_session_id).await?; + + Ok(RemoteSessionState { + session_info, + event_response, + files, + }) +} + +async fn fetch_remote_session_events( + client: &Client, + api_base_url: &str, + access_token: &str, + remote_session_id: &str, +) -> Result { + let response = client + .get(format!( + "{api_base_url}/v1/sessions/{remote_session_id}/events" + )) + .header(AUTHORIZATION, format!("Bearer {access_token}")) + .send() + .await + .map_err(|error| format!("Failed to load Cowork agent session events: {error}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "Unknown backend error".to_string()); + return Err(build_backend_error_message( + status, + &body, + AUTH_REQUIRED_MESSAGE, + )); + } + + response + .json::() + .await + .map_err(|error| format!("Failed to decode Cowork agent session events: {error}")) +} + +async fn fetch_remote_session_files( + client: &Client, + api_base_url: &str, + access_token: &str, + remote_session_id: &str, +) -> Result, String> { + let response = client + .get(format!("{api_base_url}/v1/sessions/{remote_session_id}/files")) + .header(AUTHORIZATION, format!("Bearer {access_token}")) + .send() + .await + .map_err(|error| format!("Failed to load Cowork agent session files: {error}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "Unknown backend error".to_string()); + return Err(build_backend_error_message( + status, + &body, + AUTH_REQUIRED_MESSAGE, + )); + } + + response + .json::>() + .await + .map_err(|error| format!("Failed to decode Cowork agent session files: {error}")) +} diff --git a/frontend/src-tauri/src/cowork/agent_remote/socket.rs b/frontend/src-tauri/src/cowork/agent_remote/socket.rs new file mode 100644 index 000000000..7b74a24c5 --- /dev/null +++ b/frontend/src-tauri/src/cowork/agent_remote/socket.rs @@ -0,0 +1,649 @@ +use super::desktop_dispatcher::auto_execute_external_tools; +use super::mapper::{ + extract_error_message, is_terminal_run_status, is_waiting_run_status, map_remote_run_status, +}; +use super::types::{ + RemoteAgentRunOutcome, RemoteSocketChatEvent, RemoteSocketSignal, REMOTE_SOCKET_MESSAGE_TYPE, + SOCKET_EVENT_TIMEOUT_SECS, +}; +use crate::cowork::agent_presets::DesktopRuntimePreset; +use crate::cowork::chat::{ + emit_cowork_stream_event, CoworkChatEvent, CoworkChatRunStatus, CoworkChatRuntimeEvent, + CoworkChatScope, +}; +use crate::cowork::desktop_tools::DesktopToolRuntime; +use crate::cowork::session_gateway; +use crate::cowork::string_utils::normalize_optional_string; +use crate::cowork::time_utils::now_iso; +use rust_socketio::{ClientBuilder as SocketClientBuilder, Payload, TransportType}; +use serde_json::{json, Value}; +use std::any::Any; +use std::sync::mpsc; +use std::time::{Duration, Instant}; +use tauri::async_runtime::spawn_blocking; +use tauri::AppHandle; + +pub async fn run_remote_agent_request( + api_base_url: &str, + access_token: &str, + remote_session_id: Option, + command_payload: Value, + app: AppHandle, + scope: CoworkChatScope, + desktop_runtime_preset: Option, + local_session_id: String, +) -> Result { + let api_base_url = api_base_url.to_string(); + let access_token = access_token.to_string(); + let remote_session_id_for_auth = remote_session_id.clone(); + let stream_context = RemoteStreamContext { + app, + scope, + local_session_id, + }; + + spawn_blocking(move || { + std::panic::catch_unwind(std::panic::AssertUnwindSafe( + || -> Result { + let (tx, rx) = mpsc::channel::(); + let tx_chat = tx.clone(); + let tx_error = tx.clone(); + let mut last_status = Some(CoworkChatRunStatus::Thinking); + let mut desktop_runtime = DesktopToolRuntime::default(); + + let auth_payload = if let Some(session_id) = remote_session_id_for_auth.clone() { + json!({ + "token": access_token, + "session_uuid": session_id, + }) + } else { + json!({ + "token": access_token, + }) + }; + + let socket = SocketClientBuilder::new(api_base_url.as_str()) + .transport_type(TransportType::Websocket) + .auth(auth_payload) + .on("chat_event", move |payload, _socket| { + if let Err(payload) = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + if let Some(event) = + parse_socket_payload(payload).and_then(parse_socket_chat_event) + { + let _ = tx_chat.send(RemoteSocketSignal::ChatEvent(event)); + } + })) + { + eprintln!( + "[cowork] chat_event callback panicked: {}", + panic_payload_message(payload.as_ref()) + ); + } + }) + .on("error", move |payload, _socket| { + if let Err(payload) = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let message = parse_socket_payload(payload) + .and_then(|value| { + let extracted = extract_error_message(&value); + if extracted.is_empty() { + None + } else { + Some(extracted) + } + }) + .unwrap_or_else(|| { + "Cowork agent socket returned an unknown error".to_string() + }); + let _ = tx_error.send(RemoteSocketSignal::ClientError(message)); + })) + { + eprintln!( + "[cowork] socket error callback panicked: {}", + panic_payload_message(payload.as_ref()) + ); + } + }) + .connect() + .map_err(|error| format!("Failed to connect Cowork agent socket: {error}"))?; + + let join_payload = if let Some(session_id) = remote_session_id.clone() { + json!({ "session_uuid": session_id }) + } else { + json!({}) + }; + + socket + .emit("join_session", join_payload) + .map_err(|error| format!("Failed to join Cowork agent session: {error}"))?; + + let active_session_id = wait_for_socket_session( + &rx, + remote_session_id.clone(), + &stream_context, + &mut last_status, + )?; + + // Inject "command" into content so the backend discriminated union can resolve it. + let mut enriched_content = command_payload.clone(); + if let Some(obj) = enriched_content.as_object_mut() { + obj.insert( + "command".to_string(), + Value::String(REMOTE_SOCKET_MESSAGE_TYPE.to_string()), + ); + } + + socket + .emit( + "chat_message", + json!({ + "session_uuid": active_session_id, + "content": enriched_content, + }), + ) + .map_err(|error| format!("Failed to send Cowork agent message: {error}"))?; + + let continue_session_id = active_session_id.clone(); + let mut continue_run = |run_id: &str, + external_tool_results: Vec| + -> Result<(), String> { + socket + .emit( + "chat_message", + json!({ + "session_uuid": continue_session_id, + "content": { + "command": "cowork_continue_run", + "run_id": run_id, + "confirmed": true, + "external_tool_results": external_tool_results, + } + }), + ) + .map_err(|error| format!("Failed to continue Cowork agent run: {error}")) + }; + + wait_for_remote_run_completion( + &rx, + &stream_context, + &mut last_status, + desktop_runtime_preset.as_ref(), + &mut desktop_runtime, + &mut continue_run, + )?; + let _ = socket.disconnect(); + + Ok(RemoteAgentRunOutcome { + runtime_session_id: active_session_id, + }) + }, + )) + .map_err(|payload| { + format!( + "Cowork agent worker panicked: {}", + panic_payload_message(payload.as_ref()) + ) + })? + }) + .await + .map_err(|error| format!("Cowork agent worker failed: {error}"))? +} + +fn panic_payload_message(payload: &(dyn Any + Send)) -> String { + if let Some(message) = payload.downcast_ref::<&str>() { + (*message).to_string() + } else if let Some(message) = payload.downcast_ref::() { + message.clone() + } else { + "non-string panic payload".to_string() + } +} + +fn wait_for_socket_session( + receiver: &mpsc::Receiver, + fallback_session_id: Option, + stream_context: &RemoteStreamContext, + last_status: &mut Option, +) -> Result { + if let Some(session_id) = fallback_session_id { + return Ok(session_id); + } + + let started_at = Instant::now(); + let timeout = Duration::from_secs(SOCKET_EVENT_TIMEOUT_SECS); + + while started_at.elapsed() < timeout { + match receiver.recv_timeout(Duration::from_millis(250)) { + Ok(RemoteSocketSignal::ChatEvent(event)) => { + emit_remote_socket_event(stream_context, &event, last_status); + if event.event_type == "system" { + if let Some(session_id) = event + .content + .get("session_id") + .and_then(Value::as_str) + .and_then(normalize_optional_string) + { + return Ok(session_id); + } + } else if event.event_type == "error" { + let message = extract_error_message(&event.content); + if !message.is_empty() { + return Err(message); + } + } + } + Ok(RemoteSocketSignal::ClientError(message)) => return Err(message), + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err( + "Cowork agent socket disconnected before session initialization".to_string(), + ); + } + } + } + + Err("Timed out while waiting for Cowork agent session initialization".to_string()) +} + +fn wait_for_remote_run_completion( + receiver: &mpsc::Receiver, + stream_context: &RemoteStreamContext, + last_status: &mut Option, + desktop_runtime_preset: Option<&DesktopRuntimePreset>, + desktop_runtime: &mut DesktopToolRuntime, + continue_run: &mut dyn FnMut(&str, Vec) -> Result<(), String>, +) -> Result<(), String> { + let started_at = Instant::now(); + let timeout = Duration::from_secs(SOCKET_EVENT_TIMEOUT_SECS); + let mut pending_continue_run_id: Option = None; + + while started_at.elapsed() < timeout { + match receiver.recv_timeout(Duration::from_millis(250)) { + Ok(RemoteSocketSignal::ChatEvent(event)) => { + emit_remote_socket_event(stream_context, &event, last_status); + if event.event_type == "error" { + let message = extract_error_message(&event.content); + if !message.is_empty() { + return Err(message); + } + } + + if event.event_type == "tool_confirmation" { + if let Some(run_id) = event.run_id.as_deref() { + if let Some(external_tool_results) = auto_execute_external_tools( + &stream_context.app, + stream_context.scope, + desktop_runtime_preset, + &stream_context.local_session_id, + &event.content, + desktop_runtime, + )? { + continue_run(run_id, external_tool_results)?; + pending_continue_run_id = Some(run_id.to_string()); + continue; + } + } + } + + if should_ignore_terminal_after_continue(&event, pending_continue_run_id.as_deref()) + { + continue; + } + if should_clear_pending_continue(&event, pending_continue_run_id.as_deref()) { + pending_continue_run_id = None; + } + + if let Some(run_status) = event.run_status.as_deref() { + if is_terminal_run_status(run_status) || is_waiting_run_status(run_status) { + return Ok(()); + } + } + + match event.event_type.as_str() { + "stream_complete" + | "complete" + | "agent_response_interrupted" + | "tool_confirmation" => { + return Ok(()); + } + _ => {} + } + } + Ok(RemoteSocketSignal::ClientError(message)) => return Err(message), + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err("Cowork agent socket disconnected before the run completed".to_string()); + } + } + } + + Err("Timed out while waiting for Cowork agent run completion".to_string()) +} + +fn should_ignore_terminal_after_continue( + event: &RemoteSocketChatEvent, + pending_continue_run_id: Option<&str>, +) -> bool { + let Some(pending_run_id) = pending_continue_run_id else { + return false; + }; + if event.run_id.as_deref() != Some(pending_run_id) { + return false; + } + + matches!(event.event_type.as_str(), "stream_complete" | "complete") + || matches!( + event.run_status.as_deref(), + Some(run_status) if is_terminal_run_status(run_status) + ) +} + +fn should_clear_pending_continue( + event: &RemoteSocketChatEvent, + pending_continue_run_id: Option<&str>, +) -> bool { + let Some(pending_run_id) = pending_continue_run_id else { + return false; + }; + if event.run_id.as_deref() != Some(pending_run_id) { + return false; + } + + matches!( + event.event_type.as_str(), + "agent_continue" + | "processing" + | "agent_thinking_start" + | "tool_call" + | "tool_result" + | "agent_thinking_delta" + | "agent_thinking" + | "agent_response_delta" + | "agent_response" + | "tool_confirmation" + ) || matches!(event.run_status.as_deref(), Some("running")) +} + +fn parse_socket_payload(payload: Payload) -> Option { + #[allow(deprecated)] + match payload { + Payload::Text(values) => values.into_iter().next(), + Payload::String(text) => serde_json::from_str::(&text).ok(), + Payload::Binary(_) => None, + } +} + +/// Map backend dotted event names (e.g. "agent.stream.complete") to the short +/// names the Cowork runtime uses for status derivation and terminal detection. +pub fn normalize_event_name(raw: &str) -> String { + match raw { + // System / connection events + "connection.established" => "system".to_string(), + "system.error" => "error".to_string(), + "system.pong" => "pong".to_string(), + + // Agent lifecycle + "agent.processing" => "processing".to_string(), + "agent.reasoning.start" => "agent_thinking_start".to_string(), + "agent.reasoning.delta" => "agent_thinking_delta".to_string(), + "agent.reasoning" => "agent_thinking".to_string(), + "agent.thinking.delta" => "agent_thinking_delta".to_string(), + "agent.thinking" => "agent_thinking".to_string(), + "agent.response.delta" => "agent_response_delta".to_string(), + "agent.response" => "agent_response".to_string(), + "agent.response.interrupted" => "agent_response_interrupted".to_string(), + "agent.continue" => "agent_continue".to_string(), + "agent.stream.complete" => "stream_complete".to_string(), + "agent.complete" => "complete".to_string(), + "agent.error" => "error".to_string(), + + // Tool events + "tool.call" => "tool_call".to_string(), + "tool.result" => "tool_result".to_string(), + "tool.confirmation" => "tool_confirmation".to_string(), + "tool.user_input" => "waiting_for_user_input".to_string(), + + // Metrics / user message + "metrics.updated" => "metrics_update".to_string(), + "session.user_message" => "user_message".to_string(), + "user.message" => "user_message".to_string(), + + // Fallback: strip "agent." / "system." prefix and replace dots with underscores + other => { + let stripped = other + .strip_prefix("agent.") + .or_else(|| other.strip_prefix("system.")) + .or_else(|| other.strip_prefix("tool.")) + .unwrap_or(other); + stripped.replace('.', "_") + } + } +} + +#[cfg(test)] +mod tests { + use super::super::types::RemoteSocketChatEvent; + use super::{ + normalize_event_name, parse_socket_chat_event, should_clear_pending_continue, + should_ignore_terminal_after_continue, + }; + use serde_json::json; + + #[test] + fn normalizes_session_user_message_to_user_message() { + assert_eq!(normalize_event_name("session.user_message"), "user_message"); + } + + #[test] + fn normalizes_legacy_user_message_to_user_message() { + assert_eq!(normalize_event_name("user.message"), "user_message"); + } + + #[test] + fn normalizes_reasoning_events_to_thinking_events() { + assert_eq!( + normalize_event_name("agent.reasoning.start"), + "agent_thinking_start" + ); + assert_eq!( + normalize_event_name("agent.reasoning.delta"), + "agent_thinking_delta" + ); + assert_eq!(normalize_event_name("agent.reasoning"), "agent_thinking"); + } + + #[test] + fn ignores_stale_stream_complete_after_auto_continue() { + let event = RemoteSocketChatEvent { + event_id: Some("evt-1".to_string()), + event_type: "stream_complete".to_string(), + content: json!({}), + created_at: Some("2026-04-06T00:00:00Z".to_string()), + run_status: None, + run_id: Some("run-1".to_string()), + }; + + assert!(should_ignore_terminal_after_continue(&event, Some("run-1"))); + assert!(!should_ignore_terminal_after_continue( + &event, + Some("run-2") + )); + } + + #[test] + fn clears_pending_continue_once_resumed_events_arrive() { + let event = RemoteSocketChatEvent { + event_id: Some("evt-2".to_string()), + event_type: "agent_continue".to_string(), + content: json!({}), + created_at: Some("2026-04-06T00:00:01Z".to_string()), + run_status: Some("running".to_string()), + run_id: Some("run-1".to_string()), + }; + + assert!(should_clear_pending_continue(&event, Some("run-1"))); + assert!(!should_clear_pending_continue(&event, Some("run-2"))); + } + + #[test] + fn parses_run_id_from_content_when_top_level_run_id_is_missing() { + let event = parse_socket_chat_event(json!({ + "id": "evt-3", + "name": "agent.tool.confirmation", + "content": { + "run_id": "run-1", + "tools": [] + } + })) + .expect("socket event should parse"); + + assert_eq!(event.run_id.as_deref(), Some("run-1")); + assert_eq!(event.event_type, "tool_confirmation"); + } +} + +fn parse_socket_chat_event(value: Value) -> Option { + let record = value.as_object()?; + let event_id = record + .get("id") + .and_then(Value::as_str) + .and_then(normalize_optional_string); + + // Backend sends "name" (e.g. "agent.stream.complete"), fall back to "type" + let raw_event_type = record + .get("name") + .and_then(Value::as_str) + .or_else(|| record.get("type").and_then(Value::as_str))?; + let event_type = normalize_event_name(raw_event_type); + + let content = record.get("content").cloned().unwrap_or(Value::Null); + + // Backend sends "timestamp" as a float (epoch seconds), convert to ISO string + let created_at = record + .get("created_at") + .and_then(Value::as_str) + .and_then(normalize_optional_string) + .or_else(|| { + record + .get("timestamp") + .and_then(Value::as_str) + .and_then(normalize_optional_string) + }) + .or_else(|| { + // timestamp may be a numeric epoch — just stringify it + record.get("timestamp").and_then(|v| { + if v.is_f64() || v.is_i64() || v.is_u64() { + Some(v.to_string()) + } else { + None + } + }) + }); + + // run_status may be top-level or inside content + let run_status = record + .get("run_status") + .and_then(Value::as_str) + .and_then(normalize_optional_string) + .or_else(|| { + content + .get("run_status") + .and_then(Value::as_str) + .and_then(normalize_optional_string) + }); + + let run_id = record + .get("run_id") + .and_then(Value::as_str) + .and_then(normalize_optional_string) + .or_else(|| { + content + .get("run_id") + .and_then(Value::as_str) + .and_then(normalize_optional_string) + }); + + Some(RemoteSocketChatEvent { + event_id, + event_type, + content, + created_at, + run_status, + run_id, + }) +} + +#[derive(Clone)] +struct RemoteStreamContext { + app: AppHandle, + scope: CoworkChatScope, + local_session_id: String, +} + +fn emit_remote_socket_event( + stream_context: &RemoteStreamContext, + event: &RemoteSocketChatEvent, + last_status: &mut Option, +) { + let derived_status = derive_status_from_remote_event(event); + let runtime_event = CoworkChatEvent::Runtime(CoworkChatRuntimeEvent { + event_type: "runtime.event".to_string(), + scope: stream_context.scope, + session_id: stream_context.local_session_id.clone(), + runtime_event_type: event.event_type.clone(), + runtime_event_id: event.event_id.clone(), + runtime_created_at: event.created_at.clone(), + run_status: event.run_status.clone(), + emitted_at: now_iso(), + content: event.content.clone(), + }); + + if let CoworkChatEvent::Runtime(runtime_event_payload) = &runtime_event { + let _ = session_gateway::persist_stream_runtime_event( + &stream_context.app, + runtime_event_payload, + derived_status, + ); + } + let _ = emit_cowork_stream_event(&stream_context.app, &runtime_event); + + if let Some(status) = derived_status { + if last_status.as_ref() == Some(&status) { + return; + } + + *last_status = Some(status); + let status_event = session_gateway::build_status_updated_event( + stream_context.scope, + stream_context.local_session_id.clone(), + status, + ); + let _ = emit_cowork_stream_event(&stream_context.app, &status_event); + } +} + +fn derive_status_from_remote_event(event: &RemoteSocketChatEvent) -> Option { + if let Some(run_status) = event.run_status.as_deref() { + return Some(map_remote_run_status(Some(run_status))); + } + + match event.event_type.as_str() { + "processing" + | "agent_thinking_start" + | "agent_thinking_delta" + | "agent_thinking" + | "agent_response_delta" + | "agent_response" + | "agent_continue" + | "tool_call" + | "tool_result" + | "system" => Some(CoworkChatRunStatus::Thinking), + "tool_confirmation" | "waiting_for_user_input" => { + Some(CoworkChatRunStatus::WaitingForInput) + } + "complete" | "stream_complete" => Some(CoworkChatRunStatus::Completed), + "error" | "agent_response_interrupted" => Some(CoworkChatRunStatus::Stopped), + _ => None, + } +} diff --git a/frontend/src-tauri/src/cowork/agent_remote/types.rs b/frontend/src-tauri/src/cowork/agent_remote/types.rs new file mode 100644 index 000000000..f440d7fe5 --- /dev/null +++ b/frontend/src-tauri/src/cowork/agent_remote/types.rs @@ -0,0 +1,150 @@ +use crate::cowork::agent_presets::shared::{DesktopSkillCapability, DesktopToolCapability}; +use crate::cowork::chat::CoworkGitHubRepositoryContext; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const V1_REQUIRED_MESSAGE: &str = + "Cowork backend session is not using api_version=v1, so IIAgent is not active for this session."; +pub const V1_BACKEND_REQUIRED_MESSAGE: &str = + "Cowork backend is still creating sessions without api_version=v1. Enable the backend agent_v1_version_toggle, then start a new Cowork chat session."; +pub const SOCKET_EVENT_TIMEOUT_SECS: u64 = 180; +pub const REMOTE_AGENT_TYPE: &str = "general"; +pub const REMOTE_BUILD_MODE: &str = "build"; +pub const REMOTE_SOCKET_MESSAGE_TYPE: &str = "cowork_query"; + +#[derive(Debug, Serialize)] +pub(super) struct RemoteAgentCommandContent { + pub model_id: String, + pub provider: String, + pub source: String, + pub agent_type: &'static str, + pub tool_args: RemoteAgentToolArgs, + pub thinking_tokens: u32, + pub text: String, + pub resume: bool, + pub files: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub requested_capabilities: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub github_repository: Option, + pub build_mode: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct RemoteRequestedCapabilities { + #[serde(skip_serializing_if = "Option::is_none")] + pub client_tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_skills: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub core_tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub core_skills: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub connector: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct RemoteAgentToolArgs { + pub task_agent: bool, + pub deep_research: bool, + pub pdf: bool, + pub media_generation: bool, + pub audio_generation: bool, + pub browser: bool, + pub enable_reviewer: bool, + pub design_document: bool, + pub codex_tools: bool, + pub claude_code: bool, +} + +#[derive(Debug)] +pub struct RemoteAgentRunOutcome { + pub runtime_session_id: String, +} + +#[derive(Debug, Deserialize)] +pub struct RemoteSessionInfo { + #[serde(default)] + pub api_version: Option, + #[serde(default)] + pub(super) updated_at: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct RemoteAvailableModelsResponse { + #[serde(default)] + pub models: Vec, +} + +#[derive(Debug, Deserialize)] +pub(super) struct RemoteAvailableModel { + pub id: String, + #[serde(default, alias = "api_type")] + pub provider: Option, + #[serde(default)] + pub source: Option, +} + +pub struct RemoteModelSelection { + pub source: String, + pub provider: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct RemoteSessionEventsResponse { + #[serde(default)] + pub events: Vec, + #[serde(default)] + pub run_status: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct RemoteSessionEventRecord { + pub id: String, + /// Backend sends "event_type" (dotted name like "agent.response"); + /// also accept legacy "type" field via alias. + #[serde(default, alias = "type")] + pub event_type: String, + #[serde(default)] + pub content: Value, + #[serde(default, alias = "timestamp")] + pub created_at: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct RemoteSessionFile { + pub id: String, + pub name: String, + pub size: u64, + #[serde(default)] + pub content_type: String, + #[serde(default)] + pub url: Option, +} + +#[derive(Debug, Clone)] +pub(super) struct RemoteSocketChatEvent { + pub event_id: Option, + pub event_type: String, + pub content: Value, + pub created_at: Option, + pub run_status: Option, + pub run_id: Option, +} + +#[derive(Debug)] +pub(super) enum RemoteSocketSignal { + ChatEvent(RemoteSocketChatEvent), + ClientError(String), +} + +pub(super) struct RemoteSessionState { + pub session_info: RemoteSessionInfo, + pub event_response: RemoteSessionEventsResponse, + pub files: Vec, +} diff --git a/frontend/src-tauri/src/cowork/bootstrap.rs b/frontend/src-tauri/src/cowork/bootstrap.rs new file mode 100644 index 000000000..a74db5c91 --- /dev/null +++ b/frontend/src-tauri/src/cowork/bootstrap.rs @@ -0,0 +1,44 @@ +use std::any::Any; +use std::backtrace::Backtrace; +use std::io::Write; + +pub fn install_panic_diagnostics() { + std::panic::set_hook(Box::new(|panic_info| { + let location = panic_info + .location() + .map(|loc| format!("{}:{}:{}", loc.file(), loc.line(), loc.column())) + .unwrap_or_else(|| "".to_string()); + let thread = std::thread::current(); + let thread_name = thread.name().unwrap_or(""); + let payload = panic_payload_message(panic_info.payload()); + let backtrace = Backtrace::force_capture(); + let message = format!( + "[ii-agent panic] thread={thread_name} location={location}\nmessage: {payload}\nbacktrace:\n{backtrace}\n" + ); + + eprintln!("{message}"); + + let log_path = std::env::temp_dir().join("ii-agent-panic.log"); + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + { + let _ = writeln!(file, "{message}"); + } + })); +} + +pub fn maybe_startup_exit_code_from_env_args() -> Option { + crate::cowork::desktop_runtime::wasm::chunking::splitter::maybe_run_pdf_helper_from_env_args() +} + +fn panic_payload_message(payload: &(dyn Any + Send)) -> String { + if let Some(message) = payload.downcast_ref::<&str>() { + (*message).to_string() + } else if let Some(message) = payload.downcast_ref::() { + message.clone() + } else { + "non-string panic payload".to_string() + } +} diff --git a/frontend/src-tauri/src/cowork/chat.rs b/frontend/src-tauri/src/cowork/chat.rs new file mode 100644 index 000000000..ffd334e47 --- /dev/null +++ b/frontend/src-tauri/src/cowork/chat.rs @@ -0,0 +1,226 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tauri::{AppHandle, Emitter}; + +pub const COWORK_STREAM_EVENT_NAME: &str = "cowork://stream"; + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +pub enum CoworkChatScope { + #[serde(rename = "homepage")] + Homepage, + #[serde(rename = "intelligent-folder", alias = "intelligent_folder")] + IntelligentFolder, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum CoworkChatMessageRole { + User, + Assistant, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CoworkChatRunStatus { + Idle, + Thinking, + WaitingForInput, + Completed, + Stopped, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CoworkAgentRuntimeKind { + Remote, + Local, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct CoworkChatMessage { + pub id: String, + pub role: CoworkChatMessageRole, + pub content: String, + pub created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_think_message: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct CoworkChatFile { + pub id: String, + pub file_name: String, + pub file_size: u64, + pub content_type: String, + pub created_at: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct CoworkChatSessionSummary { + pub id: String, + pub scope: CoworkChatScope, + pub title: String, + pub preview: String, + pub updated_at: String, + pub message_count: usize, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct CoworkChatSessionDetail { + pub id: String, + pub scope: CoworkChatScope, + pub title: String, + pub preview: String, + pub updated_at: String, + pub message_count: usize, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub runtime_kind: Option, + #[serde(skip_serializing_if = "Option::is_none", alias = "remote_session_id")] + pub runtime_session_id: Option, + pub messages: Vec, + #[serde(default, alias = "remote_events")] + pub runtime_events: Vec, + pub files: Vec, + pub run_status: CoworkChatRunStatus, +} + +impl CoworkChatSessionDetail { + pub fn normalize_runtime_binding(&mut self) { + if self.runtime_kind.is_none() + && (self.runtime_session_id.is_some() || !self.runtime_events.is_empty()) + { + self.runtime_kind = Some(CoworkAgentRuntimeKind::Remote); + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct CoworkGitHubRepositoryContext { + pub owner: String, + pub name: String, + pub full_name: String, + pub default_branch: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct CoworkChatToolSettings { + pub web_search: bool, + pub web_visit: bool, + pub image_search: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub code_interpreter: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_video: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct CoworkAgentOverrides { + #[serde(skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_names: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub skill_names: Option>, + #[serde(skip_serializing_if = "Option::is_none", alias = "agent_config")] + pub runtime_options: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct CoworkChatSendMessageRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub runtime_kind: Option, + pub scope: CoworkChatScope, + pub content: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_context: Option, + pub model_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub github_repository: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_overrides: Option, +} + +impl CoworkChatSendMessageRequest { + pub fn requested_runtime_kind(&self) -> CoworkAgentRuntimeKind { + self.runtime_kind.unwrap_or(CoworkAgentRuntimeKind::Remote) + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct CoworkChatSessionEvent { + #[serde(rename = "type")] + pub event_type: String, + pub session: CoworkChatSessionSummary, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct CoworkChatStatusEvent { + #[serde(rename = "type")] + pub event_type: String, + pub scope: CoworkChatScope, + pub session_id: String, + pub status: CoworkChatRunStatus, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct CoworkChatMessageEvent { + #[serde(rename = "type")] + pub event_type: String, + pub scope: CoworkChatScope, + pub session_id: String, + pub message: CoworkChatMessage, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct CoworkChatFilesEvent { + #[serde(rename = "type")] + pub event_type: String, + pub scope: CoworkChatScope, + pub session_id: String, + pub files: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct CoworkChatRuntimeEvent { + #[serde(rename = "type")] + pub event_type: String, + pub scope: CoworkChatScope, + pub session_id: String, + #[serde(alias = "remote_event_type")] + pub runtime_event_type: String, + #[serde(skip_serializing_if = "Option::is_none", alias = "remote_event_id")] + pub runtime_event_id: Option, + #[serde(skip_serializing_if = "Option::is_none", alias = "remote_created_at")] + pub runtime_created_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub run_status: Option, + pub emitted_at: String, + pub content: Value, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(untagged)] +pub enum CoworkChatEvent { + Session(CoworkChatSessionEvent), + Message(CoworkChatMessageEvent), + Files(CoworkChatFilesEvent), + Status(CoworkChatStatusEvent), + Runtime(CoworkChatRuntimeEvent), +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct CoworkChatSendMessageResponse { + pub session_id: String, + pub events: Vec, +} + +pub fn emit_cowork_stream_event(app: &AppHandle, event: &CoworkChatEvent) -> Result<(), String> { + app.emit(COWORK_STREAM_EVENT_NAME, event) + .map_err(|error| format!("Failed to emit cowork stream event: {error}")) +} diff --git a/frontend/src-tauri/src/cowork/chat_commands.rs b/frontend/src-tauri/src/cowork/chat_commands.rs new file mode 100644 index 000000000..16ddfcacf --- /dev/null +++ b/frontend/src-tauri/src/cowork/chat_commands.rs @@ -0,0 +1,79 @@ +use crate::cowork::agent_remote::auth::{get_auth_context, RemoteAuthState}; +use crate::cowork::agent_remote::cancel::emit_cancel_signal; +use crate::cowork::chat::{CoworkChatRunStatus, CoworkChatScope, CoworkChatSessionDetail}; +use crate::cowork::session_gateway; +use crate::cowork::time_utils::now_iso; +use tauri::{AppHandle, State}; + +#[tauri::command] +pub async fn stop_cowork_chat_session( + app: AppHandle, + remote_auth_state: State<'_, RemoteAuthState>, + scope: CoworkChatScope, + session_id: String, +) -> Result { + let mut local_session = session_gateway::load_local_session(&app, scope, &session_id)?; + + // Only dispatch a remote cancel if the session is actually mid-run. This + // guarantees the "already completed" / "stopped twice" path never opens a + // pointless socket connection. + let should_dispatch_remote_cancel = matches!( + local_session.base().run_status, + CoworkChatRunStatus::Thinking | CoworkChatRunStatus::WaitingForInput + ); + + if should_dispatch_remote_cancel { + let runtime_session_id = local_session.base().runtime_session_id.clone(); + // Snapshot the auth context into owned values BEFORE any .await so + // the `State<'_, _>` reference never spans an await point (keeps the + // future Send and sidesteps Rust's borrow-across-await diagnostics). + let auth = get_auth_context(&remote_auth_state); + let access_token = auth.access_token; + let api_base_url = auth.api_base_url; + + match (runtime_session_id, access_token, api_base_url) { + (Some(remote_session_id), Some(access_token), Some(api_base_url)) => { + // Fire-and-forget: log any failure and continue with local + // stop. A network hiccup here must not prevent the user from + // seeing the UI transition to Stopped. + if let Err(error) = + emit_cancel_signal(api_base_url, access_token, remote_session_id).await + { + eprintln!("[cowork] remote cancel dispatch failed: {error}"); + } + } + (None, _, _) => { + // Session was never bound to a Python session UUID yet — no + // remote run to cancel. + eprintln!( + "[cowork] stop: no runtime_session_id for local session {session_id}; \ + skipping remote cancel" + ); + } + _ => { + // Auth missing — user may be signed out. Local stop still runs. + eprintln!( + "[cowork] stop: auth context missing; skipping remote cancel for {session_id}" + ); + } + } + } + + // Existing local state update (unchanged). + { + let base = local_session.base_mut(); + if matches!( + base.run_status, + CoworkChatRunStatus::Thinking | CoworkChatRunStatus::WaitingForInput + ) { + base.run_status = CoworkChatRunStatus::Stopped; + base.updated_at = now_iso(); + } + } + + session_gateway::sync_folder_result_tree(&mut local_session)?; + local_session = session_gateway::persist_local_session(&app, local_session)?; + session_gateway::emit_local_terminal_state(&app, &local_session, false); + + Ok(local_session.base().clone()) +} diff --git a/frontend/src-tauri/src/cowork/desktop_runtime/host.rs b/frontend/src-tauri/src/cowork/desktop_runtime/host.rs new file mode 100644 index 000000000..1ae27764c --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_runtime/host.rs @@ -0,0 +1,58 @@ +//! Host runtime backend. +//! +//! This backend is a thin policy layer over the existing native tool +//! execution path. The actual tools (bash, read, write, edit, apply_patch, +//! list_dir, glob, grep, todo_write) still own their execution. This module +//! centralises the documentation of what "host execution" means so that +//! tools and skills can reason about it uniformly alongside the WASM +//! backend. +//! +//! The host backend is appropriate for: +//! +//! * file discovery, reading, and structural edits +//! * regex / glob search +//! * shell-based local development tasks +//! * any task that already depends on the user's native environment +//! +//! It is **not** appropriate for isolated processing of user documents +//! with tight resource limits — use [`super::wasm`] for that. + +#![allow(dead_code)] + +use super::{RuntimeKind, RuntimeLimits}; + +/// Marker type for the host runtime. Kept separate from [`super::wasm`] so +/// downstream modules can branch on backend kind without touching tool +/// implementations. +#[derive(Debug, Clone, Copy, Default)] +pub struct HostRuntime; + +impl HostRuntime { + pub const KIND: RuntimeKind = RuntimeKind::Host; + + /// Default resource policy for host execution. The host backend only + /// enforces the wall timeout; memory/fuel limits are advisory and are + /// exposed so that host-side tools which want to match WASM policy can + /// read the same values. + pub fn default_limits() -> RuntimeLimits { + RuntimeLimits::defaults() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_runtime_reports_host_kind() { + assert_eq!(HostRuntime::KIND, RuntimeKind::Host); + } + + #[test] + fn host_runtime_exposes_default_limits() { + let limits = HostRuntime::default_limits(); + assert!(limits.wall_timeout.as_secs() > 0); + assert!(limits.max_memory_bytes > 0); + assert!(limits.max_fuel > 0); + } +} diff --git a/frontend/src-tauri/src/cowork/desktop_runtime/mod.rs b/frontend/src-tauri/src/cowork/desktop_runtime/mod.rs new file mode 100644 index 000000000..41ccfe10d --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_runtime/mod.rs @@ -0,0 +1,61 @@ +//! Desktop execution runtimes for cowork desktop tools and skills. +//! +//! The desktop runtime owns execution policy: resource limits, scratch +//! workspace layout, and cleanup semantics for skill-driven processing. +//! Tools plug into a runtime by asking the runtime to execute something — +//! they never spawn isolated work directly. +//! +//! Two backends are currently defined: +//! +//! * [`host`] — the existing native execution backend used by normal local +//! file and shell tools. It is declared here only so that runtime policy +//! lives in one place; the tool modules continue to run on the host +//! directly. +//! * [`wasm`] — the isolated backend used by skill-driven processing tasks +//! that should not run as native host code. Modules are sandboxed by a +//! wasmtime engine with memory and fuel limits. + +pub mod host; +pub mod wasm; + +use std::time::Duration; + +/// Resource limits shared between desktop runtimes. +/// +/// These limits are expressed once so both the host and the WASM backend +/// can enforce a common policy. Individual backends may ignore a limit +/// that does not apply to them (for example, the host backend only enforces +/// `wall_timeout`, while the WASM backend enforces all three). +#[derive(Debug, Clone, Copy)] +pub struct RuntimeLimits { + /// Maximum wall-clock duration of a single runtime call. + pub wall_timeout: Duration, + /// Maximum linear memory (in bytes) a WASM module may allocate. + pub max_memory_bytes: usize, + /// Maximum number of WASM instructions (fuel units) per call. + pub max_fuel: u64, +} + +impl RuntimeLimits { + /// Default limits used when a skill or tool does not override them. + pub const fn defaults() -> Self { + Self { + wall_timeout: Duration::from_secs(30), + max_memory_bytes: 256 * 1024 * 1024, // 256 MiB + max_fuel: 1_000_000_000, + } + } +} + +impl Default for RuntimeLimits { + fn default() -> Self { + Self::defaults() + } +} + +/// Identifier for a desktop runtime backend. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuntimeKind { + Host, + Wasm, +} diff --git a/frontend/src-tauri/src/cowork/desktop_runtime/wasm/chunking/docx.rs b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/chunking/docx.rs new file mode 100644 index 000000000..b477f0445 --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/chunking/docx.rs @@ -0,0 +1,94 @@ +//! Docx-specific chunk planner. +//! +//! OOXML containers (docx) cannot be host-side split like PDF because +//! they share internal resources (styles, relationships, numbering). +//! For files > 100 MB the planner raises the memory ceiling to 1 GB +//! and lets the guest parse the full file in a single call. + +use super::{ChunkPlan, ChunkPlanner}; +use crate::cowork::desktop_runtime::RuntimeLimits; +use serde_json::Value; +use std::path::Path; +use std::time::Duration; + +const SMALL_THRESHOLD: u64 = 20 * 1024 * 1024; +const LARGE_THRESHOLD: u64 = 100 * 1024 * 1024; +const MEDIUM_MEMORY: usize = 768 * 1024 * 1024; +const LARGE_MEMORY: usize = 1024 * 1024 * 1024; // 1 GB + +#[derive(Debug, Default, Clone, Copy)] +pub struct DocxChunker; + +impl ChunkPlanner for DocxChunker { + fn plan( + &self, + file_path: &Path, + op: &str, + _base: &Value, + ) -> Result, String> { + if op != "extract_text" { + return Ok(None); + } + let size = std::fs::metadata(file_path) + .map_err(|e| format!("docx chunker: stat {}: {e}", file_path.display()))? + .len(); + Ok(Some(plan_from_size(size))) + } +} + +pub fn plan_from_size(size: u64) -> ChunkPlan { + if size <= SMALL_THRESHOLD { + return ChunkPlan::single_default(); + } + if size <= LARGE_THRESHOLD { + let mut limits = RuntimeLimits::defaults(); + limits.max_memory_bytes = MEDIUM_MEMORY; + return ChunkPlan::Single { limits }; + } + // > 100 MB: single call with raised memory (1 GB) and extended + // timeout. Multi-chunk is pointless because the guest (docx-rs) + // loads the full zip container before slicing by paragraph range. + let mut limits = RuntimeLimits::defaults(); + limits.max_memory_bytes = LARGE_MEMORY; + limits.wall_timeout = Duration::from_secs(120); + ChunkPlan::Single { limits } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn small_file_single_default_limits() { + let plan = plan_from_size(5 * 1024 * 1024); + match plan { + ChunkPlan::Single { limits } => { + assert_eq!(limits.max_memory_bytes, RuntimeLimits::defaults().max_memory_bytes); + } + _ => panic!("expected Single"), + } + } + + #[test] + fn medium_file_single_raised_memory() { + let plan = plan_from_size(50 * 1024 * 1024); + match plan { + ChunkPlan::Single { limits } => { + assert_eq!(limits.max_memory_bytes, MEDIUM_MEMORY); + } + _ => panic!("expected Single"), + } + } + + #[test] + fn large_file_single_1gb_memory() { + let plan = plan_from_size(200 * 1024 * 1024); + match plan { + ChunkPlan::Single { limits } => { + assert_eq!(limits.max_memory_bytes, LARGE_MEMORY); + assert_eq!(limits.wall_timeout, Duration::from_secs(120)); + } + _ => panic!("expected Single with 1GB, got Multi"), + } + } +} diff --git a/frontend/src-tauri/src/cowork/desktop_runtime/wasm/chunking/mod.rs b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/chunking/mod.rs new file mode 100644 index 000000000..7ad7786ec --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/chunking/mod.rs @@ -0,0 +1,324 @@ +//! Host-side chunking policy for desktop WASM calls. +//! +//! Large input documents can exhaust the 256 MB per-Store memory limit +//! if processed in a single `wasm_run` call. This module owns the +//! decision of "does this call need to be split, and if so how?" so +//! the `wasm_run` tool can stay agnostic about individual file formats. +//! +//! ## Concepts +//! +//! * [`ChunkPlan`] — the decision returned by a planner. Either +//! [`ChunkPlan::Single`] (run one WASM call as-is) or +//! [`ChunkPlan::Multi`] (run several WASM calls in sequence and +//! merge the results). +//! * [`ChunkPlanner`] — a format-specific strategy that inspects a +//! host-side file and produces a [`ChunkPlan`]. Each supported format +//! gets its own planner implementation; [`pdf::PdfChunker`] is the +//! first. +//! * [`classify_and_plan`] — the entry point the `wasm_run` tool calls. +//! Picks the right planner for the target module, runs it, and falls +//! back to a single-call plan when no planner exists for the module. +//! +//! ## Why not inside the guest? +//! +//! The obvious alternative is to let each guest module detect its own +//! memory pressure and stream. Two reasons the decision lives in the +//! host instead: +//! +//! 1. Guest modules run with a hard memory ceiling that the host +//! cannot raise mid-call. Splitting the work across Stores is the +//! only way to keep individual peak memory bounded. +//! 2. The host can run chunks in parallel later (each chunk gets its +//! own Store). Inside one guest, parallelism requires WASI threads, +//! which are not part of the `wasm32-wasip1` target we ship. +//! +//! ## Current scope +//! +//! v1 is size-based only: the planner classifies by file size on disk +//! and picks a fixed chunk granularity. It does not parse the document +//! to count pages — that would require pulling `lopdf` into the host +//! binary, which we want to avoid. The granularity heuristic is tuned +//! so the guest, once given a page_range, never tries to load more +//! than ~100 MB of PDF into memory at once. + +pub mod docx; +pub mod pdf; +pub mod pptx; +pub mod splitter; +pub mod xlsx; + +use crate::cowork::desktop_runtime::RuntimeLimits; +use serde_json::Value; +use std::path::Path; + +/// Result of planning a `wasm_run` call against a specific input file. +/// +/// A plan is always safe to ignore: if the caller does not know how to +/// execute multi-chunk plans it can treat [`ChunkPlan::Multi`] as if it +/// were a single call and accept the memory risk. That keeps the plan +/// structure additive — callers adopt chunking at their own pace. +#[derive(Debug, Clone)] +pub enum ChunkPlan { + /// Run the call as-is, optionally with overridden limits (e.g. a + /// larger memory ceiling for borderline inputs that do not need + /// splitting but do need headroom). + Single { limits: RuntimeLimits }, + /// Run several calls in order, each with its own input_json + /// override and limits. The caller merges the structured results + /// back together via [`MergeStrategy`]. + Multi { + chunks: Vec, + merge: MergeStrategy, + /// When `true`, the dispatcher should use host-side file + /// splitting (via [`splitter::split_pdf_pages`]) to produce + /// per-chunk input files instead of passing the full file with + /// a range overlay. This eliminates guest-side full-file memory + /// pressure for formats where host splitting is implemented + /// (currently: PDF only). + use_host_split: bool, + }, +} + +impl ChunkPlan { + pub fn single_default() -> Self { + Self::Single { + limits: RuntimeLimits::defaults(), + } + } + + pub fn is_multi(&self) -> bool { + matches!(self, Self::Multi { .. }) + } + + pub fn uses_host_split(&self) -> bool { + matches!(self, Self::Multi { use_host_split: true, .. }) + } + + pub fn chunk_count(&self) -> usize { + match self { + Self::Single { .. } => 1, + Self::Multi { chunks, .. } => chunks.len(), + } + } +} + +/// One unit of work in a multi-chunk plan. +#[derive(Debug, Clone)] +pub struct Chunk { + /// Zero-based chunk index, used for ordering merged results. + pub index: usize, + /// Human-readable label for logging and error reporting, for + /// example `"pages 1-50"`. + pub label: String, + /// Fields to merge into `input_json` before dispatching the call. + /// The chunker produces this from its own heuristics (for example, + /// a PDF chunker inserts `{"page_range": [1, 50]}`). + pub input_json_overlay: Value, + /// Resource limits for this specific chunk. Usually the same for + /// every chunk in a plan, but kept per-chunk so unusual cases can + /// bump limits on the last chunk if needed. + pub limits: RuntimeLimits, +} + +/// Strategy the caller uses to merge the output of a multi-chunk plan +/// back into a single structured result. +/// +/// Each variant corresponds to a specific `(module, op)` pair. Adding a +/// new chunkable operation means adding a variant here and a matching +/// implementation in [`merge_chunk_outputs`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MergeStrategy { + /// Concatenate `output.json.pages[]` across chunks, ordered by + /// chunk `index`. The merged result uses the first chunk's + /// `total_pages` value. + PdfExtractTextPages, + /// Concatenate `output.json.paragraphs[]` across chunks for the + /// docx `extract_text` op. + DocxExtractTextParagraphs, + /// Concatenate `output.json.rows[]` across chunks for the xlsx + /// `read_sheet` op. + XlsxReadSheetRows, + /// Concatenate `output.json.slides[]` across chunks for the pptx + /// `extract_text` op. + PptxExtractTextSlides, +} + +/// A format-specific strategy for deciding whether a call needs to be +/// chunked and, if so, how. +/// +/// Implementations should be pure functions of `(file_path, op, +/// base_input_json)` so the planner can be unit tested without +/// touching the WASM runtime. +pub trait ChunkPlanner { + /// Plan a call for `file_path` with the given base `input_json`. + /// + /// Returns `Ok(None)` when the planner does not know how to handle + /// the call — the caller should fall through to another planner or + /// the default single-call plan. Returns `Err(_)` only for hard + /// I/O errors that should abort the whole tool call. + fn plan( + &self, + file_path: &Path, + op: &str, + base_input_json: &Value, + ) -> Result, String>; +} + +/// Top-level entry point used by `wasm_run`. +/// +/// The `module` parameter lets the dispatcher pick the right planner +/// without having to know which format the file is. Today only +/// `pdf_processor` has a planner; other modules fall through to the +/// default single-call plan. +pub fn classify_and_plan( + module: &str, + file_path: Option<&Path>, + op: Option<&str>, + base_input_json: &Value, +) -> Result { + let Some(file_path) = file_path else { + return Ok(ChunkPlan::single_default()); + }; + let Some(op) = op else { + return Ok(ChunkPlan::single_default()); + }; + + let planner: Option> = match module { + "pdf_processor" => Some(Box::new(pdf::PdfChunker::default())), + "docx_processor" => Some(Box::new(docx::DocxChunker::default())), + "xlsx_processor" => Some(Box::new(xlsx::XlsxChunker::default())), + "pptx_processor" => Some(Box::new(pptx::PptxChunker::default())), + _ => None, + }; + + let Some(planner) = planner else { + return Ok(ChunkPlan::single_default()); + }; + + match planner.plan(file_path, op, base_input_json)? { + Some(plan) => Ok(plan), + None => Ok(ChunkPlan::single_default()), + } +} + +/// Merge structured outputs of multi-chunk plans back into one +/// `output.json`-style value. +/// +/// The caller hands in the chunk outputs in chunk-index order (the +/// sequential dispatcher in `wasm_run` owns ordering). This function +/// knows the merge shape for each [`MergeStrategy`] variant and +/// produces the final `Value` that gets formatted into the tool +/// result. +pub fn merge_chunk_outputs( + strategy: MergeStrategy, + chunk_outputs: &[Value], +) -> Result { + match strategy { + MergeStrategy::PdfExtractTextPages => { + merge_array_field(chunk_outputs, "pages", "total_pages", "pdf_processor") + } + MergeStrategy::DocxExtractTextParagraphs => merge_array_field( + chunk_outputs, + "paragraphs", + "total_paragraphs", + "docx_processor", + ), + MergeStrategy::XlsxReadSheetRows => { + merge_array_field(chunk_outputs, "rows", "total_rows", "xlsx_processor") + } + MergeStrategy::PptxExtractTextSlides => { + merge_array_field(chunk_outputs, "slides", "total_slides", "pptx_processor") + } + } +} + +/// Generic merge: concatenate `array_field` across all chunk outputs in +/// order, then copy `total_field` from the first chunk. Shared by every +/// merge strategy since they all produce the same shape. +fn merge_array_field( + chunk_outputs: &[Value], + array_field: &str, + total_field: &str, + module_label: &str, +) -> Result { + let mut merged_items: Vec = Vec::new(); + let mut total_value: Option = None; + for (index, chunk) in chunk_outputs.iter().enumerate() { + let items = chunk + .get(array_field) + .and_then(Value::as_array) + .ok_or_else(|| { + format!( + "{module_label} chunk {index}: output.json has no '{array_field}' array" + ) + })?; + merged_items.extend(items.iter().cloned()); + if total_value.is_none() { + total_value = chunk.get(total_field).and_then(Value::as_u64); + } + } + let mut merged = serde_json::Map::new(); + merged.insert(array_field.to_string(), Value::Array(merged_items)); + if let Some(total) = total_value { + merged.insert(total_field.to_string(), Value::from(total)); + } + Ok(Value::Object(merged)) +} + + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn classify_and_plan_falls_back_for_unknown_module() { + let plan = + classify_and_plan("no_such_module", None, Some("extract_text"), &Value::Null).unwrap(); + assert!(matches!(plan, ChunkPlan::Single { .. })); + } + + #[test] + fn classify_and_plan_falls_back_when_no_file_path() { + let plan = + classify_and_plan("pdf_processor", None, Some("extract_text"), &Value::Null).unwrap(); + assert!(matches!(plan, ChunkPlan::Single { .. })); + } + + #[test] + fn merge_pdf_extract_text_concatenates_in_order() { + let outputs = vec![ + json!({ + "pages": ["p1", "p2", "p3"], + "page_offset": 1, + "total_pages": 7, + }), + json!({ + "pages": ["p4", "p5"], + "page_offset": 4, + "total_pages": 7, + }), + json!({ + "pages": ["p6", "p7"], + "page_offset": 6, + "total_pages": 7, + }), + ]; + let merged = merge_chunk_outputs(MergeStrategy::PdfExtractTextPages, &outputs).unwrap(); + let pages = merged + .get("pages") + .and_then(Value::as_array) + .expect("pages"); + assert_eq!(pages.len(), 7); + assert_eq!(pages[0].as_str(), Some("p1")); + assert_eq!(pages[6].as_str(), Some("p7")); + assert_eq!(merged.get("total_pages").and_then(Value::as_u64), Some(7)); + } + + #[test] + fn merge_pdf_extract_text_rejects_missing_pages_array() { + let outputs = vec![json!({ "oops": true })]; + let err = merge_chunk_outputs(MergeStrategy::PdfExtractTextPages, &outputs).unwrap_err(); + assert!(err.contains("no 'pages' array")); + } +} diff --git a/frontend/src-tauri/src/cowork/desktop_runtime/wasm/chunking/pdf.rs b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/chunking/pdf.rs new file mode 100644 index 000000000..c903ebc6c --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/chunking/pdf.rs @@ -0,0 +1,368 @@ +//! PDF-specific chunk planner. +//! +//! Classifies a PDF file by **file size on disk** (no parsing) and +//! picks a page-range chunk granularity that keeps individual guest +//! Store memory usage within the 256 MB ceiling. This is a heuristic — +//! it does not know the actual page count of the document, it only +//! knows the file size. The guest module clamps out-of-range chunk +//! requests gracefully, so overshoot is safe: if we ask for pages +//! `[201, 250]` on a 180-page document, the guest returns an empty +//! `pages` array for that chunk rather than erroring out. +//! +//! ## Heuristic +//! +//! | File size | Plan | Pages per chunk | +//! |---|---|---| +//! | ≤ 20 MB | Single (default limits) | — | +//! | 20–100 MB | Single (larger memory ceiling) | — | +//! | > 100 MB | Multi, sequential | 20 | +//! +//! The chunk count for the "Multi" bucket is hard-capped at +//! [`MAX_CHUNKS`] so a pathologically large file cannot generate +//! thousands of wasm calls. In practice this means the host gives up +//! on documents larger than ~6 GB and falls back to a single +//! best-effort call with raised limits. +//! +//! Only `extract_text` is chunkable in v1. Any other op routes through +//! the default single-call plan. + +use super::splitter; +use super::{Chunk, ChunkPlan, ChunkPlanner, MergeStrategy}; +use crate::cowork::desktop_runtime::RuntimeLimits; +use serde_json::{json, Value}; +use std::path::Path; +use std::time::Duration; + +/// Upper bound on the number of chunks the planner will emit for a +/// single call. Prevents runaway multi-call dispatch on pathologically +/// large files. +pub const MAX_CHUNKS: usize = 256; + +/// Page count per chunk in the "large file" bucket. Tuned so the +/// guest-side `lopdf` parser can process the slice comfortably inside +/// the 256 MB Store limit for typical PDFs. +pub const LARGE_FILE_CHUNK_PAGES: u32 = 20; + +/// Very rough per-page PDF size estimate used to turn "how big is the +/// file" into "how many pages does this cover, roughly". 50 KB per +/// page is pessimistic enough that the last chunk usually overshoots +/// the real document — and the guest clamps overshoot to an empty +/// `pages` array, so we are always safe to ask for more than the +/// document holds. +const BYTES_PER_PAGE_ESTIMATE: u64 = 50 * 1024; + +/// File size threshold at which the plan stops using default limits +/// and starts either bumping memory or splitting the call. +const SMALL_FILE_THRESHOLD: u64 = 20 * 1024 * 1024; + +/// File size threshold at which the plan switches from "single call +/// with larger memory" to "multi chunk". +const LARGE_FILE_THRESHOLD: u64 = 100 * 1024 * 1024; + +/// Memory ceiling for the medium bucket. 768 MB gives lopdf room to +/// parse mid-size PDFs without forcing a multi-call dispatch. +const MEDIUM_MEMORY_BYTES: usize = 768 * 1024 * 1024; + +/// Memory ceiling for chunked calls. Kept at 512 MB because the chunk +/// size is already small (20 pages) so we rarely need more. +const CHUNKED_MEMORY_BYTES: usize = 512 * 1024 * 1024; + +/// Wall-clock deadline for a single chunked call. Chunked calls are +/// each smaller than a full extraction, so they finish faster; 60 s is +/// generous. +const CHUNK_WALL_TIMEOUT_SECS: u64 = 60; + +#[derive(Debug, Default, Clone, Copy)] +pub struct PdfChunker; + +impl ChunkPlanner for PdfChunker { + fn plan( + &self, + file_path: &Path, + op: &str, + _base_input_json: &Value, + ) -> Result, String> { + // Only extract_text benefits from chunking — metadata is a + // constant-time read that already fits in any memory budget. + if op != "extract_text" { + return Ok(None); + } + + let metadata = std::fs::metadata(file_path).map_err(|error| { + format!( + "pdf chunker: cannot stat {} for chunk planning: {}", + file_path.display(), + error + ) + })?; + let size = metadata.len(); + + // For large files, use the real page count from `lopdf` on the + // host side so chunk boundaries are accurate. For small/medium + // files where memory is not at risk, skip the extra parse. + if size > LARGE_FILE_THRESHOLD { + return Ok(Some(plan_large_file( + file_path, + size, + splitter::count_pdf_pages(file_path), + )?)); + } + + Ok(Some(plan_from_size(size))) + } +} + +fn plan_large_file( + file_path: &Path, + size: u64, + page_count: Result, +) -> Result { + let real_page_count = page_count.map_err(|error| { + format!( + "pdf chunker: failed to count pages for large PDF {}: {error}", + file_path.display() + ) + })?; + Ok(plan_from_page_count(real_page_count, size)) +} + +/// Plan using the real page count (available for large PDFs parsed on +/// the host side via [`splitter::count_pdf_pages`]). +pub fn plan_from_page_count(page_count: u32, _file_size: u64) -> ChunkPlan { + if page_count == 0 { + return ChunkPlan::single_default(); + } + + let chunk_pages = LARGE_FILE_CHUNK_PAGES; + let ideal_chunks = page_count.div_ceil(chunk_pages).max(1) as usize; + + if ideal_chunks <= 1 { + // Small number of pages — even though the file is large (heavy + // images), a single call should be fine with raised memory. + let mut limits = RuntimeLimits::defaults(); + limits.max_memory_bytes = MEDIUM_MEMORY_BYTES; + limits.wall_timeout = Duration::from_secs(120); + return ChunkPlan::Single { limits }; + } + + if ideal_chunks > MAX_CHUNKS { + let mut limits = RuntimeLimits::defaults(); + limits.max_memory_bytes = MEDIUM_MEMORY_BYTES; + limits.wall_timeout = Duration::from_secs(180); + return ChunkPlan::Single { limits }; + } + + let mut chunks = Vec::with_capacity(ideal_chunks); + let mut chunk_limits = RuntimeLimits::defaults(); + chunk_limits.max_memory_bytes = CHUNKED_MEMORY_BYTES; + chunk_limits.wall_timeout = Duration::from_secs(CHUNK_WALL_TIMEOUT_SECS); + + // For accurate chunking, use the `split_source` flag that tells + // the wasm_run dispatcher to call the host-side splitter instead + // of passing a page_range param to the guest with the full file. + // This is handled externally by checking whether the chunk plan + // was built with real page counts; see `plan.uses_host_splitting`. + for index in 0..ideal_chunks { + let start = (index as u32) * chunk_pages + 1; + let end = (start + chunk_pages - 1).min(page_count); + chunks.push(Chunk { + index, + label: format!("pages {start}-{end}"), + input_json_overlay: json!({ + "page_range": [start, end] + }), + limits: chunk_limits, + }); + } + + ChunkPlan::Multi { + chunks, + merge: MergeStrategy::PdfExtractTextPages, + use_host_split: true, // accurate page count → host can split + } +} + +/// Pure decision function — extracted so tests can drive the heuristic +/// with synthetic sizes without touching the filesystem. +pub fn plan_from_size(size: u64) -> ChunkPlan { + if size <= SMALL_FILE_THRESHOLD { + return ChunkPlan::Single { + limits: RuntimeLimits::defaults(), + }; + } + + if size <= LARGE_FILE_THRESHOLD { + let mut limits = RuntimeLimits::defaults(); + limits.max_memory_bytes = MEDIUM_MEMORY_BYTES; + return ChunkPlan::Single { limits }; + } + + // Large file: multi-chunk plan. + // + // Estimate the page count from the file size, then split into + // `LARGE_FILE_CHUNK_PAGES` page chunks. Cap at MAX_CHUNKS so a + // 100 GB corrupted input cannot generate 2 million wasm_run calls. + let estimated_pages = ((size + BYTES_PER_PAGE_ESTIMATE - 1) / BYTES_PER_PAGE_ESTIMATE) as u32; + let chunk_pages = LARGE_FILE_CHUNK_PAGES; + let ideal_chunks = estimated_pages.div_ceil(chunk_pages).max(1) as usize; + + if ideal_chunks > MAX_CHUNKS { + // Fall back to a single oversized call rather than spawning + // thousands of wasm invocations. The user will see a memory + // error if this fails — at that point the right answer is + // "too big, split manually", which the skill body documents. + let mut limits = RuntimeLimits::defaults(); + limits.max_memory_bytes = MEDIUM_MEMORY_BYTES; + limits.wall_timeout = Duration::from_secs(180); + return ChunkPlan::Single { limits }; + } + + let mut chunks = Vec::with_capacity(ideal_chunks); + let mut chunk_limits = RuntimeLimits::defaults(); + chunk_limits.max_memory_bytes = CHUNKED_MEMORY_BYTES; + chunk_limits.wall_timeout = Duration::from_secs(CHUNK_WALL_TIMEOUT_SECS); + + for index in 0..ideal_chunks { + let start = (index as u32) * chunk_pages + 1; + let end = start + chunk_pages - 1; + chunks.push(Chunk { + index, + label: format!("pages {start}-{end}"), + input_json_overlay: json!({ + "page_range": [start, end] + }), + limits: chunk_limits, + }); + } + + ChunkPlan::Multi { + chunks, + merge: MergeStrategy::PdfExtractTextPages, + use_host_split: false, // size-only heuristic → no real page count + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn small_file_is_single_with_default_limits() { + let plan = plan_from_size(5 * 1024 * 1024); // 5 MB + match plan { + ChunkPlan::Single { limits } => { + assert_eq!( + limits.max_memory_bytes, + RuntimeLimits::defaults().max_memory_bytes + ); + } + other => panic!("expected Single, got {:?}", other), + } + } + + #[test] + fn medium_file_is_single_with_raised_memory() { + let plan = plan_from_size(50 * 1024 * 1024); // 50 MB + match plan { + ChunkPlan::Single { limits } => { + assert_eq!(limits.max_memory_bytes, MEDIUM_MEMORY_BYTES); + } + other => panic!("expected Single, got {:?}", other), + } + } + + #[test] + fn large_file_is_multi_with_page_ranges() { + let plan = plan_from_size(200 * 1024 * 1024); // 200 MB + match plan { + ChunkPlan::Multi { chunks, merge, .. } => { + assert_eq!(merge, MergeStrategy::PdfExtractTextPages); + assert!( + !chunks.is_empty() && chunks.len() <= MAX_CHUNKS, + "chunk count {} should be reasonable", + chunks.len() + ); + // Verify chunks form contiguous, non-overlapping ranges starting at 1. + let first = chunks.first().unwrap(); + let overlay = first.input_json_overlay.as_object().expect("overlay obj"); + let range = overlay + .get("page_range") + .and_then(|v| v.as_array()) + .expect("page_range array"); + assert_eq!(range[0].as_u64(), Some(1)); + assert_eq!(range[1].as_u64(), Some(LARGE_FILE_CHUNK_PAGES as u64)); + // Second chunk starts right after the first. + let second = &chunks[1]; + let overlay2 = second.input_json_overlay.as_object().expect("overlay obj"); + let range2 = overlay2 + .get("page_range") + .and_then(|v| v.as_array()) + .expect("page_range array"); + assert_eq!(range2[0].as_u64(), Some(LARGE_FILE_CHUNK_PAGES as u64 + 1)); + } + other => panic!("expected Multi, got {:?}", other), + } + } + + #[test] + fn pathologically_large_file_falls_back_to_single() { + // 100 GB — far above the MAX_CHUNKS threshold at 20 pages × + // 50 KB per page = 1 MB per chunk. 100 GB / 1 MB = 100_000 + // chunks >> MAX_CHUNKS. The planner should refuse to generate + // that many and fall back to a single oversized call. + let plan = plan_from_size(100 * 1024 * 1024 * 1024); + assert!( + matches!(plan, ChunkPlan::Single { .. }), + "expected Single fallback, got {:?}", + plan + ); + } + + #[test] + fn large_file_page_count_failure_does_not_fall_back_to_heuristic() { + let err = plan_large_file( + Path::new("/tmp/heavy.pdf"), + LARGE_FILE_THRESHOLD + 1, + Err("helper crashed".to_string()), + ) + .expect_err("large-file helper failure should be surfaced"); + + assert!(err.contains("failed to count pages for large PDF /tmp/heavy.pdf")); + assert!(err.contains("helper crashed")); + } + + #[test] + fn non_extract_text_op_is_none() { + let chunker = PdfChunker::default(); + // Even if we point at a real file that exists, metadata op + // should bypass chunking. + let tempdir = std::env::temp_dir(); + let result = chunker.plan(&tempdir, "metadata", &Value::Null).unwrap(); + assert!(result.is_none()); + } + + /// End-to-end wiring: hand the real planner the small fixture PDF + /// from `tests/fixtures/`. It is well under 20 MB so the plan must + /// be a Single with default limits. This proves that the I/O path + /// works (stat the file, size bucket, return plan). + #[test] + fn small_fixture_pdf_is_single_plan() { + let fixture = + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/hello.pdf"); + assert!(fixture.exists(), "fixture missing: {}", fixture.display()); + let chunker = PdfChunker::default(); + let plan = chunker + .plan(&fixture, "extract_text", &Value::Null) + .expect("plan ok"); + match plan { + Some(ChunkPlan::Single { limits }) => { + assert_eq!( + limits.max_memory_bytes, + RuntimeLimits::defaults().max_memory_bytes + ); + } + other => panic!("expected Single, got {:?}", other), + } + } +} diff --git a/frontend/src-tauri/src/cowork/desktop_runtime/wasm/chunking/pptx.rs b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/chunking/pptx.rs new file mode 100644 index 000000000..2aa84d3f3 --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/chunking/pptx.rs @@ -0,0 +1,72 @@ +//! Pptx-specific chunk planner. +//! +//! Like docx, pptx cannot be host-side split. For files > 100 MB the +//! planner raises memory to 1 GB and runs a single call. + +use super::{ChunkPlan, ChunkPlanner}; +use crate::cowork::desktop_runtime::RuntimeLimits; +use serde_json::Value; +use std::path::Path; +use std::time::Duration; + +const SMALL_THRESHOLD: u64 = 20 * 1024 * 1024; +const LARGE_THRESHOLD: u64 = 100 * 1024 * 1024; +const MEDIUM_MEMORY: usize = 768 * 1024 * 1024; +const LARGE_MEMORY: usize = 1024 * 1024 * 1024; + +#[derive(Debug, Default, Clone, Copy)] +pub struct PptxChunker; + +impl ChunkPlanner for PptxChunker { + fn plan( + &self, + file_path: &Path, + op: &str, + _base: &Value, + ) -> Result, String> { + if op != "extract_text" { + return Ok(None); + } + let size = std::fs::metadata(file_path) + .map_err(|e| format!("pptx chunker: stat {}: {e}", file_path.display()))? + .len(); + Ok(Some(plan_from_size(size))) + } +} + +pub fn plan_from_size(size: u64) -> ChunkPlan { + if size <= SMALL_THRESHOLD { + return ChunkPlan::single_default(); + } + if size <= LARGE_THRESHOLD { + let mut limits = RuntimeLimits::defaults(); + limits.max_memory_bytes = MEDIUM_MEMORY; + return ChunkPlan::Single { limits }; + } + let mut limits = RuntimeLimits::defaults(); + limits.max_memory_bytes = LARGE_MEMORY; + limits.wall_timeout = Duration::from_secs(120); + ChunkPlan::Single { limits } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn small_file_single() { + let plan = plan_from_size(5 * 1024 * 1024); + assert!(matches!(plan, ChunkPlan::Single { .. })); + } + + #[test] + fn large_file_single_1gb() { + let plan = plan_from_size(200 * 1024 * 1024); + match plan { + ChunkPlan::Single { limits } => { + assert_eq!(limits.max_memory_bytes, LARGE_MEMORY); + } + _ => panic!("expected Single with 1GB"), + } + } +} diff --git a/frontend/src-tauri/src/cowork/desktop_runtime/wasm/chunking/splitter.rs b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/chunking/splitter.rs new file mode 100644 index 000000000..ebcbb0dc6 --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/chunking/splitter.rs @@ -0,0 +1,832 @@ +//! Host-side file splitting for memory-sensitive chunk dispatch. +//! +//! The key problem: guest modules like `pdf_processor` use `lopdf` which +//! calls `Document::load()` on the full file — meaning even if we pass +//! `page_range: [1, 20]`, the guest still loads the full 200 MB into +//! linear memory before extracting the requested pages. This negates +//! the memory benefit of chunking. +//! +//! The fix: split the source file **on the host side** into smaller +//! files (one per chunk), then pass each chunk-file to its own sandbox +//! call. The guest sees a small file and stays within its memory budget. +//! +//! One subtlety: parsing/splitting a huge PDF with `lopdf` can itself +//! consume a large amount of native heap. If we do that work inside the +//! Tauri process, the OS can kill the entire desktop app before Wasmtime +//! gets a chance to report a sandboxed memory error. To keep the UI +//! alive, production builds run the expensive host-side PDF work in a +//! short-lived helper process spawned from the current executable. If the +//! helper panics, aborts, or is OOM-killed, the parent sees a normal tool +//! error instead of losing the whole app. +//! +//! ## Scope +//! +//! v1 supports PDF splitting only. DOCX/XLSX/PPTX are fundamentally +//! different (zipped OOXML containers where you can't extract a subset +//! of pages without rewriting the entire archive), so they skip host-side +//! splitting and continue to pass the full file + range params to the +//! guest. This means large DOCX/XLSX/PPTX may still OOM in the guest, +//! which the skill body documents as a limitation. + +use crate::cowork::desktop_runtime::wasm::WasmRunResult; +use lopdf::Document; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::any::Any; +use std::ffi::{OsStr, OsString}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +const PDF_HELPER_FLAG: &str = "--ii-pdf-helper"; +const PDF_HELPER_COUNT_PAGES: &str = "count-pages"; +const PDF_HELPER_SPLIT_PAGES: &str = "split-pages"; +const PDF_HELPER_RUN_PROCESSOR: &str = "run-pdf-processor"; + +/// Split a PDF file into multiple smaller PDFs, each containing a +/// contiguous range of pages. Returns a list of `(chunk_file_path, +/// page_start, page_end)` tuples. The chunk files are written to +/// `output_dir` as `chunk_0.pdf`, `chunk_1.pdf`, etc. +/// +/// If the file has fewer pages than `pages_per_chunk`, a single chunk +/// containing the entire document is returned. +/// +/// This function uses `lopdf` on the host side (native, not sandboxed) +/// because it needs to parse the PDF structure to split pages. +pub fn split_pdf_pages( + source: &Path, + output_dir: &Path, + pages_per_chunk: u32, +) -> Result, String> { + if cfg!(test) { + return split_pdf_pages_in_process(source, output_dir, pages_per_chunk); + } + + let result_path = helper_result_path("split"); + let output = run_helper_process(&[ + OsString::from(PDF_HELPER_FLAG), + OsString::from(PDF_HELPER_SPLIT_PAGES), + source.as_os_str().to_os_string(), + output_dir.as_os_str().to_os_string(), + OsString::from(pages_per_chunk.to_string()), + result_path.as_os_str().to_os_string(), + ])?; + + if !output.status.success() { + let _ = fs::remove_file(&result_path); + return Err(format!( + "split_pdf: helper process failed{}{}", + format_exit_status(&output.status), + format_stderr_suffix(&output.stderr) + )); + } + + let parsed: SplitPdfPagesOutput = read_helper_result(&result_path)?; + let _ = fs::remove_file(&result_path); + Ok(parsed.chunks) +} + +fn split_pdf_pages_in_process( + source: &Path, + output_dir: &Path, + pages_per_chunk: u32, +) -> Result, String> { + run_pdf_host_operation("split_pdf", || { + if pages_per_chunk == 0 { + return Err("split_pdf: pages_per_chunk must be greater than 0".to_string()); + } + + // First pass: count pages without keeping the full doc in memory. + let page_numbers = { + let doc = lopdf::Document::load(source) + .map_err(|e| format!("split_pdf: failed to load {}: {e}", source.display()))?; + let pages = doc.get_pages(); + let mut nums: Vec = pages.keys().copied().collect(); + nums.sort_unstable(); + nums + // `doc` drops here — releases memory before we start splitting. + }; + let total = page_numbers.len() as u32; + + if total == 0 { + return Ok(vec![]); + } + + fs::create_dir_all(output_dir) + .map_err(|e| format!("split_pdf: mkdir {}: {e}", output_dir.display()))?; + + let mut chunks = Vec::new(); + let mut chunk_idx = 0u32; + let mut start = 0usize; + + while start < page_numbers.len() { + let end = (start + pages_per_chunk as usize).min(page_numbers.len()); + let chunk_pages: std::collections::HashSet = + page_numbers[start..end].iter().copied().collect(); + + // Reload from disk for each chunk so we never hold more than + // 1 copy of the document in memory at a time. This trades I/O + // (re-read source N times) for memory (peak = 1× doc size + // instead of N× doc size from cloning). + let mut chunk_doc = lopdf::Document::load(source) + .map_err(|e| format!("split_pdf: reload for chunk {chunk_idx}: {e}"))?; + + let pages_to_remove: Vec = chunk_doc + .get_pages() + .keys() + .copied() + .filter(|num| !chunk_pages.contains(num)) + .collect(); + for page_num in pages_to_remove { + chunk_doc.delete_pages(&[page_num]); + } + + let chunk_path = output_dir.join(format!("chunk_{chunk_idx}.pdf")); + chunk_doc + .save(&chunk_path) + .map_err(|e| format!("split_pdf: save chunk_{chunk_idx}: {e}"))?; + + chunks.push(PdfChunkFile { + path: chunk_path, + page_start: (start as u32) + 1, + page_end: end as u32, + total_pages: total, + }); + + start = end; + chunk_idx += 1; + // `chunk_doc` drops here — memory freed before next iteration. + } + + Ok(chunks) + }) +} + +/// Metadata about a host-side chunk file produced by [`split_pdf_pages`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PdfChunkFile { + /// Path to the chunk PDF on the host filesystem. + pub path: PathBuf, + /// 1-based start page (inclusive) relative to the original document. + pub page_start: u32, + /// 1-based end page (inclusive) relative to the original document. + pub page_end: u32, + /// Total pages in the original document. + pub total_pages: u32, +} + +/// Count pages in a PDF without extracting content. Cheap compared to +/// full extraction — we only parse the xref table and page tree, never +/// decompress streams. Used by the chunker when it needs an accurate +/// page count rather than the size-based heuristic. +pub fn count_pdf_pages(source: &Path) -> Result { + if cfg!(test) { + return count_pdf_pages_in_process(source); + } + + let result_path = helper_result_path("count"); + let output = run_helper_process(&[ + OsString::from(PDF_HELPER_FLAG), + OsString::from(PDF_HELPER_COUNT_PAGES), + source.as_os_str().to_os_string(), + result_path.as_os_str().to_os_string(), + ])?; + + if !output.status.success() { + let _ = fs::remove_file(&result_path); + return Err(format!( + "count_pdf_pages: helper process failed{}{}", + format_exit_status(&output.status), + format_stderr_suffix(&output.stderr) + )); + } + + let parsed: CountPdfPagesOutput = read_helper_result(&result_path)?; + let _ = fs::remove_file(&result_path); + Ok(parsed.page_count) +} + +pub fn run_pdf_processor_in_helper( + input_file: &Path, + input_json: Option, +) -> Result { + let request_path = helper_result_path("run-pdf-processor-request"); + let result_path = helper_result_path("run-pdf-processor-result"); + + let request = PdfProcessorRunRequest { + input_file: input_file.to_path_buf(), + input_json, + }; + write_helper_result(&request_path, &request)?; + + let output = run_helper_process(&[ + OsString::from(PDF_HELPER_FLAG), + OsString::from(PDF_HELPER_RUN_PROCESSOR), + request_path.as_os_str().to_os_string(), + result_path.as_os_str().to_os_string(), + ])?; + + let _ = fs::remove_file(&request_path); + + if !output.status.success() { + let _ = fs::remove_file(&result_path); + return Err(format!( + "run_pdf_processor: helper process failed{}{}", + format_exit_status(&output.status), + format_stderr_suffix(&output.stderr) + )); + } + + let parsed: PdfProcessorRunOutput = read_helper_result(&result_path)?; + let _ = fs::remove_file(&result_path); + Ok(parsed.result) +} + +fn count_pdf_pages_in_process(source: &Path) -> Result { + run_pdf_host_operation("count_pdf_pages", || { + let doc = lopdf::Document::load(source) + .map_err(|e| format!("count_pdf_pages: failed to load {}: {e}", source.display()))?; + Ok(doc.get_pages().len() as u32) + }) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CountPdfPagesOutput { + page_count: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct SplitPdfPagesOutput { + chunks: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PdfProcessorRunRequest { + input_file: PathBuf, + input_json: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PdfProcessorRunOutput { + result: WasmRunResult, +} + +#[derive(Debug)] +struct PdfProcessorInput { + op: String, + page_range: Option<(u32, u32)>, +} + +#[derive(Debug)] +enum PdfHelperCommand { + CountPages { + source: PathBuf, + result_path: PathBuf, + }, + SplitPages { + source: PathBuf, + output_dir: PathBuf, + pages_per_chunk: u32, + result_path: PathBuf, + }, + RunPdfProcessor { + request_path: PathBuf, + result_path: PathBuf, + }, +} + +pub fn maybe_run_pdf_helper_from_env_args() -> Option { + let args: Vec = std::env::args_os().skip(1).collect(); + let command = match parse_pdf_helper_args(args) { + Ok(Some(command)) => command, + Ok(None) => return None, + Err(error) => { + eprintln!("ii pdf helper: {error}"); + return Some(2); + } + }; + + let outcome = match command { + PdfHelperCommand::CountPages { + source, + result_path, + } => count_pdf_pages_in_process(&source).and_then(|page_count| { + write_helper_result(&result_path, &CountPdfPagesOutput { page_count }) + }), + PdfHelperCommand::SplitPages { + source, + output_dir, + pages_per_chunk, + result_path, + } => split_pdf_pages_in_process(&source, &output_dir, pages_per_chunk) + .and_then(|chunks| write_helper_result(&result_path, &SplitPdfPagesOutput { chunks })), + PdfHelperCommand::RunPdfProcessor { + request_path, + result_path, + } => read_helper_result::(&request_path) + .and_then(run_pdf_processor_in_process) + .and_then(|result| { + write_helper_result(&result_path, &PdfProcessorRunOutput { result }) + }), + }; + + match outcome { + Ok(()) => Some(0), + Err(error) => { + eprintln!("ii pdf helper: {error}"); + Some(1) + } + } +} + +fn parse_pdf_helper_args( + args: impl IntoIterator, +) -> Result, String> { + let mut args = args.into_iter(); + loop { + let Some(flag) = args.next() else { + return Ok(None); + }; + if flag == OsStr::new(PDF_HELPER_FLAG) { + break; + } + } + + let Some(command) = args.next() else { + return Err("missing helper command".to_string()); + }; + + match command.to_string_lossy().as_ref() { + PDF_HELPER_COUNT_PAGES => { + let source = args + .next() + .map(PathBuf::from) + .ok_or_else(|| "count-pages requires ".to_string())?; + let result_path = args + .next() + .map(PathBuf::from) + .ok_or_else(|| "count-pages requires ".to_string())?; + if args.next().is_some() { + return Err("count-pages received unexpected extra arguments".to_string()); + } + Ok(Some(PdfHelperCommand::CountPages { + source, + result_path, + })) + } + PDF_HELPER_SPLIT_PAGES => { + let source = args + .next() + .map(PathBuf::from) + .ok_or_else(|| "split-pages requires ".to_string())?; + let output_dir = args + .next() + .map(PathBuf::from) + .ok_or_else(|| "split-pages requires ".to_string())?; + let pages_per_chunk = args + .next() + .ok_or_else(|| "split-pages requires ".to_string())? + .to_string_lossy() + .parse::() + .map_err(|error| format!("split-pages invalid pages_per_chunk: {error}"))?; + if pages_per_chunk == 0 { + return Err("split-pages requires pages_per_chunk > 0".to_string()); + } + let result_path = args + .next() + .map(PathBuf::from) + .ok_or_else(|| "split-pages requires ".to_string())?; + if args.next().is_some() { + return Err("split-pages received unexpected extra arguments".to_string()); + } + Ok(Some(PdfHelperCommand::SplitPages { + source, + output_dir, + pages_per_chunk, + result_path, + })) + } + PDF_HELPER_RUN_PROCESSOR => { + let request_path = args + .next() + .map(PathBuf::from) + .ok_or_else(|| "run-pdf-processor requires ".to_string())?; + let result_path = args + .next() + .map(PathBuf::from) + .ok_or_else(|| "run-pdf-processor requires ".to_string())?; + if args.next().is_some() { + return Err("run-pdf-processor received unexpected extra arguments".to_string()); + } + Ok(Some(PdfHelperCommand::RunPdfProcessor { + request_path, + result_path, + })) + } + other => Err(format!("unknown helper command '{other}'")), + } +} + +fn helper_result_path(label: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + std::env::temp_dir().join(format!("ii-pdf-helper-{label}-{nanos}.json")) +} + +fn run_helper_process(args: &[OsString]) -> Result { + let current_exe = std::env::current_exe() + .map_err(|error| format!("cannot locate current executable: {error}"))?; + Command::new(current_exe) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .map_err(|error| format!("failed to spawn pdf helper process: {error}")) +} + +fn write_helper_result(result_path: &Path, value: &T) -> Result<(), String> { + if let Some(parent) = result_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("helper: mkdir {}: {error}", parent.display()))?; + } + let bytes = + serde_json::to_vec(value).map_err(|error| format!("helper: serialize result: {error}"))?; + fs::write(result_path, bytes) + .map_err(|error| format!("helper: write {}: {error}", result_path.display())) +} + +fn read_helper_result Deserialize<'de>>(result_path: &Path) -> Result { + let bytes = fs::read(result_path).map_err(|error| { + format!( + "helper result missing at {}: {error}", + result_path.display() + ) + })?; + serde_json::from_slice(&bytes).map_err(|error| { + format!( + "helper result parse failed at {}: {error}", + result_path.display() + ) + }) +} + +fn format_exit_status(status: &std::process::ExitStatus) -> String { + match status.code() { + Some(code) => format!(" (exit code {code})"), + None => " (terminated by signal)".to_string(), + } +} + +fn format_stderr_suffix(stderr: &[u8]) -> String { + let text = String::from_utf8_lossy(stderr); + let trimmed = text.trim(); + if trimmed.is_empty() { + String::new() + } else { + format!(": {trimmed}") + } +} + +fn run_pdf_host_operation(operation: &str, work: F) -> Result +where + F: FnOnce() -> Result, +{ + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(work)) { + Ok(result) => result, + Err(payload) => Err(format!( + "{operation}: host PDF processing panicked: {}", + panic_payload_message(payload.as_ref()) + )), + } +} + +fn panic_payload_message(payload: &(dyn Any + Send)) -> String { + if let Some(message) = payload.downcast_ref::<&str>() { + (*message).to_string() + } else if let Some(message) = payload.downcast_ref::() { + message.clone() + } else { + "non-string panic payload".to_string() + } +} + +fn run_pdf_processor_in_process(request: PdfProcessorRunRequest) -> Result { + run_pdf_host_operation("run_pdf_processor", || { + let started_at = std::time::Instant::now(); + let input = parse_pdf_processor_input(request.input_json.as_ref())?; + let document = Document::load(&request.input_file) + .map_err(|error| format!("failed to load {}: {error}", request.input_file.display()))?; + + let output_json = match input.op.as_str() { + "extract_text" => pdf_processor_extract_text(&document, input.page_range.as_ref())?, + "metadata" => pdf_processor_metadata(&document)?, + other => return Err(format!("unknown operation '{other}'")), + }; + + Ok(WasmRunResult { + module: "pdf_processor".to_string(), + stdout: format!("pdf_processor: op={} ok\n", input.op), + stderr: String::new(), + duration_ms: started_at.elapsed().as_millis(), + output_json: Some(output_json), + output_files: Vec::new(), + scratch_dir: None, + }) + }) +} + +fn parse_pdf_processor_input(input_json: Option<&Value>) -> Result { + let value = input_json + .cloned() + .ok_or_else(|| "failed to read /workspace/input.json: not provided".to_string())?; + let op = value + .get("op") + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| "input.json is missing the 'op' field".to_string())?; + + let page_range = match value.get("page_range") { + Some(Value::Null) | None => None, + Some(Value::Array(items)) => { + if items.len() != 2 { + return Err(format!( + "page_range must be a 2-element array [start, end], got {} elements", + items.len() + )); + } + let start = items[0] + .as_u64() + .ok_or_else(|| "page_range[0] must be a non-negative integer".to_string())?; + let end = items[1] + .as_u64() + .ok_or_else(|| "page_range[1] must be a non-negative integer".to_string())?; + Some((start as u32, end as u32)) + } + Some(other) => { + return Err(format!( + "page_range must be an array or null, got {}", + json_kind(other) + )); + } + }; + + Ok(PdfProcessorInput { op, page_range }) +} + +fn json_kind(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "bool", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +fn pdf_processor_extract_text( + document: &Document, + page_range: Option<&(u32, u32)>, +) -> Result { + let pages = document.get_pages(); + let mut ordered: Vec = pages.keys().copied().collect(); + ordered.sort_unstable(); + let total_pages = ordered.len() as u32; + + let (slice, page_offset) = if let Some(&(start, end)) = page_range { + if start == 0 { + return Err("page_range start must be 1 or greater".to_string()); + } + if start > end { + return Ok(serde_json::json!({ + "pages": Vec::::new(), + "page_offset": start, + "total_pages": total_pages, + })); + } + + let start_idx = (start - 1) as usize; + if start_idx >= ordered.len() { + return Ok(serde_json::json!({ + "pages": Vec::::new(), + "page_offset": start, + "total_pages": total_pages, + })); + } + + let end_idx = (end as usize).min(ordered.len()); + (&ordered[start_idx..end_idx], start) + } else { + (&ordered[..], 1u32) + }; + + let mut pages_out = Vec::with_capacity(slice.len()); + for &page_num in slice { + let text = document + .extract_text(&[page_num]) + .map_err(|error| format!("page {page_num} extraction failed: {error}"))?; + pages_out.push(text); + } + + Ok(serde_json::json!({ + "pages": pages_out, + "page_offset": page_offset, + "total_pages": total_pages, + })) +} + +fn pdf_processor_metadata(document: &Document) -> Result { + let page_count = document.get_pages().len(); + let info = document.trailer.get(b"Info").ok(); + let mut title: Option = None; + let mut author: Option = None; + + if let Some(info_ref) = info { + if let Ok(object_id) = info_ref.as_reference() { + if let Ok(info_dict) = document + .get_object(object_id) + .and_then(|object| object.as_dict()) + { + title = read_pdf_text_field(info_dict, b"Title"); + author = read_pdf_text_field(info_dict, b"Author"); + } + } + } + + Ok(serde_json::json!({ + "title": title, + "author": author, + "page_count": page_count, + })) +} + +fn read_pdf_text_field(dict: &lopdf::Dictionary, key: &[u8]) -> Option { + let object = dict.get(key).ok()?; + let bytes = object.as_str().ok()?; + Some(String::from_utf8_lossy(bytes).into_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn count_pages_on_fixture() { + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/hello.pdf"); + let count = count_pdf_pages(&fixture).expect("count ok"); + assert_eq!(count, 1); + } + + #[test] + fn split_single_page_produces_one_chunk() { + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/hello.pdf"); + let output_dir = std::env::temp_dir().join(format!( + "split-test-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or_default() + )); + let chunks = split_pdf_pages(&fixture, &output_dir, 10).expect("split ok"); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].page_start, 1); + assert_eq!(chunks[0].page_end, 1); + assert_eq!(chunks[0].total_pages, 1); + assert!(chunks[0].path.exists()); + // Verify chunk is a valid PDF by loading it. + let chunk_doc = lopdf::Document::load(&chunks[0].path).expect("chunk is valid pdf"); + assert_eq!(chunk_doc.get_pages().len(), 1); + fs::remove_dir_all(&output_dir).ok(); + } + + #[test] + fn helper_args_ignore_normal_app_launch() { + let parsed = parse_pdf_helper_args(Vec::::new()).expect("parse ok"); + assert!(parsed.is_none()); + } + + #[test] + fn helper_args_parse_count_pages() { + let parsed = parse_pdf_helper_args([ + OsString::from(PDF_HELPER_FLAG), + OsString::from(PDF_HELPER_COUNT_PAGES), + OsString::from("/tmp/input.pdf"), + OsString::from("/tmp/result.json"), + ]) + .expect("parse ok"); + + match parsed { + Some(PdfHelperCommand::CountPages { + source, + result_path, + }) => { + assert_eq!(source, PathBuf::from("/tmp/input.pdf")); + assert_eq!(result_path, PathBuf::from("/tmp/result.json")); + } + other => panic!("expected CountPages, got {other:?}"), + } + } + + #[test] + fn helper_args_find_flag_after_prefix_args() { + let parsed = parse_pdf_helper_args([ + OsString::from("--tauri-dev"), + OsString::from(PDF_HELPER_FLAG), + OsString::from(PDF_HELPER_COUNT_PAGES), + OsString::from("/tmp/input.pdf"), + OsString::from("/tmp/result.json"), + ]) + .expect("parse ok"); + + match parsed { + Some(PdfHelperCommand::CountPages { + source, + result_path, + }) => { + assert_eq!(source, PathBuf::from("/tmp/input.pdf")); + assert_eq!(result_path, PathBuf::from("/tmp/result.json")); + } + other => panic!("expected CountPages, got {other:?}"), + } + } + + #[test] + fn helper_args_parse_split_pages() { + let parsed = parse_pdf_helper_args([ + OsString::from(PDF_HELPER_FLAG), + OsString::from(PDF_HELPER_SPLIT_PAGES), + OsString::from("/tmp/input.pdf"), + OsString::from("/tmp/chunks"), + OsString::from("20"), + OsString::from("/tmp/result.json"), + ]) + .expect("parse ok"); + + match parsed { + Some(PdfHelperCommand::SplitPages { + source, + output_dir, + pages_per_chunk, + result_path, + }) => { + assert_eq!(source, PathBuf::from("/tmp/input.pdf")); + assert_eq!(output_dir, PathBuf::from("/tmp/chunks")); + assert_eq!(pages_per_chunk, 20); + assert_eq!(result_path, PathBuf::from("/tmp/result.json")); + } + other => panic!("expected SplitPages, got {other:?}"), + } + } + + #[test] + fn pdf_processor_in_process_extracts_text_without_wasm() { + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/hello.pdf"); + let result = run_pdf_processor_in_process(PdfWasmRunRequest { + input_file: fixture, + input_json: Some(serde_json::json!({ "op": "extract_text" })), + session_id: "test-session".to_string(), + wall_timeout_secs: 30, + max_memory_bytes: 256 * 1024 * 1024, + max_fuel: 1_000_000, + }) + .expect("native pdf processor runs"); + + assert_eq!(result.module, "pdf_processor"); + assert!(result.stdout.contains("pdf_processor: op=extract_text ok")); + let output = result.output_json.expect("output json"); + let pages = output + .get("pages") + .and_then(Value::as_array) + .expect("pages array"); + assert_eq!(pages.len(), 1); + } + + #[test] + fn pdf_processor_in_process_handles_page_range_contract() { + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/hello.pdf"); + let result = run_pdf_processor_in_process(PdfWasmRunRequest { + input_file: fixture, + input_json: Some(serde_json::json!({ + "op": "extract_text", + "page_range": [10, 20] + })), + session_id: "test-session".to_string(), + wall_timeout_secs: 30, + max_memory_bytes: 256 * 1024 * 1024, + max_fuel: 1_000_000, + }) + .expect("native pdf processor runs"); + + let output = result.output_json.expect("output json"); + let pages = output + .get("pages") + .and_then(Value::as_array) + .expect("pages array"); + assert!(pages.is_empty()); + assert_eq!(output.get("page_offset").and_then(Value::as_u64), Some(10)); + assert_eq!(output.get("total_pages").and_then(Value::as_u64), Some(1)); + } +} diff --git a/frontend/src-tauri/src/cowork/desktop_runtime/wasm/chunking/xlsx.rs b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/chunking/xlsx.rs new file mode 100644 index 000000000..a5fb39b3c --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/chunking/xlsx.rs @@ -0,0 +1,72 @@ +//! Xlsx-specific chunk planner. +//! +//! Like docx, xlsx cannot be host-side split. For files > 100 MB the +//! planner raises memory to 1 GB and runs a single call. + +use super::{ChunkPlan, ChunkPlanner}; +use crate::cowork::desktop_runtime::RuntimeLimits; +use serde_json::Value; +use std::path::Path; +use std::time::Duration; + +const SMALL_THRESHOLD: u64 = 20 * 1024 * 1024; +const LARGE_THRESHOLD: u64 = 100 * 1024 * 1024; +const MEDIUM_MEMORY: usize = 768 * 1024 * 1024; +const LARGE_MEMORY: usize = 1024 * 1024 * 1024; + +#[derive(Debug, Default, Clone, Copy)] +pub struct XlsxChunker; + +impl ChunkPlanner for XlsxChunker { + fn plan( + &self, + file_path: &Path, + op: &str, + _base: &Value, + ) -> Result, String> { + if op != "read_sheet" { + return Ok(None); + } + let size = std::fs::metadata(file_path) + .map_err(|e| format!("xlsx chunker: stat {}: {e}", file_path.display()))? + .len(); + Ok(Some(plan_from_size(size))) + } +} + +pub fn plan_from_size(size: u64) -> ChunkPlan { + if size <= SMALL_THRESHOLD { + return ChunkPlan::single_default(); + } + if size <= LARGE_THRESHOLD { + let mut limits = RuntimeLimits::defaults(); + limits.max_memory_bytes = MEDIUM_MEMORY; + return ChunkPlan::Single { limits }; + } + let mut limits = RuntimeLimits::defaults(); + limits.max_memory_bytes = LARGE_MEMORY; + limits.wall_timeout = Duration::from_secs(120); + ChunkPlan::Single { limits } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn small_file_single() { + let plan = plan_from_size(5 * 1024 * 1024); + assert!(matches!(plan, ChunkPlan::Single { .. })); + } + + #[test] + fn large_file_single_1gb() { + let plan = plan_from_size(200 * 1024 * 1024); + match plan { + ChunkPlan::Single { limits } => { + assert_eq!(limits.max_memory_bytes, LARGE_MEMORY); + } + _ => panic!("expected Single with 1GB"), + } + } +} diff --git a/frontend/src-tauri/src/cowork/desktop_runtime/wasm/freshness.rs b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/freshness.rs new file mode 100644 index 000000000..44194a476 --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/freshness.rs @@ -0,0 +1,104 @@ +//! Module freshness check utilities. +//! +//! At compile time, `include_bytes!` embeds the `.wasm` file bytes into +//! the binary. But there's no mechanism to detect if the `.wasm` file +//! is stale (older than the guest source code). This module provides a +//! runtime check that can log a warning if the compiled module timestamp +//! is older than expected. This is purely a dev-experience helper and +//! does not affect production behavior. +//! +//! ## Usage +//! +//! Call `check_module_freshness()` during runtime startup (e.g., inside +//! `register_builtin_modules`). It compares the mtime of source files +//! against the mtime of the compiled .wasm artifact. If the source is +//! newer, it can print a warning to stderr when explicitly enabled. +//! +//! In release builds (production), this is a no-op because the paths +//! are relative to the source tree which doesn't exist on end-user +//! machines. +//! +//! To avoid noisy logs during normal desktop development, warnings are +//! disabled by default. Set `II_AGENT_WASM_FRESHNESS_WARN=1` (or `true`, +//! `yes`, `on`) to enable them. + +use std::path::Path; + +/// Check whether a compiled `.wasm` module is fresh relative to its +/// guest source. Returns `true` if fresh or if the check cannot be +/// performed (missing files, release build, etc.). +/// +/// Logs a warning to stderr when stale if +/// `II_AGENT_WASM_FRESHNESS_WARN` is enabled. +pub fn check_freshness(module_name: &str, wasm_path: &str, source_dir: &str) -> bool { + let wasm = Path::new(wasm_path); + let source = Path::new(source_dir); + + if !wasm.exists() || !source.exists() { + return true; // can't check — assume fresh + } + + let wasm_mtime = match wasm.metadata().and_then(|m| m.modified()) { + Ok(t) => t, + Err(_) => return true, + }; + + // Walk source dir and find the newest .rs file. + let newest_source = match find_newest_rs(source) { + Some(t) => t, + None => return true, + }; + + if newest_source > wasm_mtime { + if !warnings_enabled() { + return false; + } + eprintln!( + "[warn] desktop_runtime: module '{module_name}' may be stale. \ + Source in {source_dir} is newer than {wasm_path}. \ + Rebuild with: cd {source_dir} && cargo build --target wasm32-wasip1 --release && cp target/wasm32-wasip1/release/{module_name}.wasm {wasm_path}" + ); + return false; + } + true +} + +fn warnings_enabled() -> bool { + std::env::var("II_AGENT_WASM_FRESHNESS_WARN") + .ok() + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) + .unwrap_or(false) +} + +fn find_newest_rs(dir: &Path) -> Option { + let mut newest: Option = None; + let entries = std::fs::read_dir(dir).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if let Some(t) = find_newest_rs(&path) { + newest = Some(newest.map_or(t, |cur| cur.max(t))); + } + } else if path.extension().is_some_and(|ext| ext == "rs") { + if let Ok(mtime) = path.metadata().and_then(|m| m.modified()) { + newest = Some(newest.map_or(mtime, |cur| cur.max(mtime))); + } + } + } + newest +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn check_freshness_returns_true_for_missing_paths() { + assert!(check_freshness("foo", "/nonexistent/foo.wasm", "/nonexistent/src")); + } +} diff --git a/frontend/src-tauri/src/cowork/desktop_runtime/wasm/guest/.gitignore b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/guest/.gitignore new file mode 100644 index 000000000..2c96eb1b6 --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/guest/.gitignore @@ -0,0 +1,2 @@ +target/ +Cargo.lock diff --git a/frontend/src-tauri/src/cowork/desktop_runtime/wasm/guest/docx_processor/Cargo.toml b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/guest/docx_processor/Cargo.toml new file mode 100644 index 000000000..c03205075 --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/guest/docx_processor/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "docx_processor" +version = "0.1.0" +edition = "2021" +description = "WASI preview1 module for the desktop docx skill. Parses Word OOXML containers with docx-rs inside the cowork WASM runtime." +license = "MIT" + +[[bin]] +name = "docx_processor" +path = "src/main.rs" + +[dependencies] +docx-rs = { version = "0.4", default-features = false } +serde_json = { version = "1", default-features = false, features = ["std"] } + +[profile.release] +opt-level = "z" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/frontend/src-tauri/src/cowork/desktop_runtime/wasm/guest/docx_processor/src/main.rs b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/guest/docx_processor/src/main.rs new file mode 100644 index 000000000..4da5550a0 --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/guest/docx_processor/src/main.rs @@ -0,0 +1,166 @@ +//! docx_processor — WASI preview1 module for the desktop `docx` skill. +//! +//! Execution contract (matches `docx.skill.md`): +//! +//! * input: +//! - `/workspace/input.json` — one of: +//! - `{ "op": "extract_text" }` +//! - `{ "op": "extract_text", "paragraph_range": [start, end] }` +//! 1-based inclusive paragraph numbers. Out-of-range requests +//! are clamped to the document length. +//! - `{ "op": "count_paragraphs" }` +//! - `/workspace/inputs/input.docx` — the Word document to process +//! * output: +//! - `/workspace/output.json` — for extract_text: +//! `{ "paragraphs": string[], "paragraph_offset": n, "total_paragraphs": m }` +//! for count_paragraphs: +//! `{ "total_paragraphs": n }` +//! - stdout — short "ok" line for human debugging +//! * exit code 0 on success, 1 on any failure. +//! +//! docx-rs is a pure-Rust DOCX reader/writer; it compiles cleanly to +//! `wasm32-wasip1` with `default-features = false`. The module stays +//! small because it only uses the reader path. + +use std::fs; +use std::io::Write; + +use docx_rs::*; +use serde_json::{json, Value}; + +const INPUT_JSON: &str = "/workspace/input.json"; +const INPUT_DOCX: &str = "/workspace/inputs/input.docx"; +const OUTPUT_JSON: &str = "/workspace/output.json"; + +fn main() { + if let Err(error) = run() { + let _ = writeln!(std::io::stderr(), "docx_processor error: {error}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), String> { + let input = read_input()?; + let bytes = fs::read(INPUT_DOCX) + .map_err(|error| format!("failed to read {INPUT_DOCX}: {error}"))?; + let doc = read_docx(&bytes).map_err(|error| format!("docx parse failed: {error:?}"))?; + + let paragraphs = collect_paragraphs(&doc); + + let output = match input.op.as_str() { + "extract_text" => extract_text(¶graphs, input.paragraph_range.as_ref())?, + "count_paragraphs" => json!({ "total_paragraphs": paragraphs.len() as u64 }), + other => return Err(format!("unknown operation '{other}'")), + }; + + fs::write(OUTPUT_JSON, output.to_string()) + .map_err(|error| format!("failed to write {OUTPUT_JSON}: {error}"))?; + println!("docx_processor: op={} ok", input.op); + Ok(()) +} + +struct InputSpec { + op: String, + paragraph_range: Option<(u32, u32)>, +} + +fn read_input() -> Result { + let bytes = fs::read(INPUT_JSON) + .map_err(|error| format!("failed to read {INPUT_JSON}: {error}"))?; + let value: Value = serde_json::from_slice(&bytes) + .map_err(|error| format!("failed to parse {INPUT_JSON}: {error}"))?; + let op = value + .get("op") + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| "input.json is missing 'op'".to_string())?; + let paragraph_range = parse_range(&value, "paragraph_range")?; + Ok(InputSpec { + op, + paragraph_range, + }) +} + +fn parse_range(value: &Value, key: &str) -> Result, String> { + match value.get(key) { + Some(Value::Null) | None => Ok(None), + Some(Value::Array(items)) => { + if items.len() != 2 { + return Err(format!( + "{key} must be a 2-element array, got {}", + items.len() + )); + } + let start = items[0] + .as_u64() + .ok_or_else(|| format!("{key}[0] must be a non-negative integer"))?; + let end = items[1] + .as_u64() + .ok_or_else(|| format!("{key}[1] must be a non-negative integer"))?; + Ok(Some((start as u32, end as u32))) + } + Some(other) => Err(format!("{key} must be array or null, got {other:?}")), + } +} + +/// Walk the docx document tree and collect every paragraph's plain +/// text in reading order. We intentionally flatten away run styling, +/// tables, headers, footers — v1 only cares about body text. Future +/// ops can walk the tree more carefully. +fn collect_paragraphs(doc: &Docx) -> Vec { + let mut out: Vec = Vec::new(); + for child in &doc.document.children { + if let DocumentChild::Paragraph(p) = child { + out.push(paragraph_text(p)); + } + } + out +} + +fn paragraph_text(paragraph: &Paragraph) -> String { + let mut text = String::new(); + for child in ¶graph.children { + if let ParagraphChild::Run(run) = child { + for run_child in &run.children { + if let RunChild::Text(t) = run_child { + text.push_str(&t.text); + } + } + } + } + text +} + +fn extract_text(paragraphs: &[String], range: Option<&(u32, u32)>) -> Result { + let total = paragraphs.len() as u32; + let (slice, offset) = if let Some(&(start, end)) = range { + if start == 0 { + return Err("paragraph_range start must be 1 or greater".to_string()); + } + if start > end { + return Ok(json!({ + "paragraphs": Vec::::new(), + "paragraph_offset": start, + "total_paragraphs": total, + })); + } + let start_idx = (start - 1) as usize; + if start_idx >= paragraphs.len() { + return Ok(json!({ + "paragraphs": Vec::::new(), + "paragraph_offset": start, + "total_paragraphs": total, + })); + } + let end_idx = (end as usize).min(paragraphs.len()); + (¶graphs[start_idx..end_idx], start) + } else { + (¶graphs[..], 1u32) + }; + + Ok(json!({ + "paragraphs": slice, + "paragraph_offset": offset, + "total_paragraphs": total, + })) +} diff --git a/frontend/src-tauri/src/cowork/desktop_runtime/wasm/guest/pdf_processor/Cargo.toml b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/guest/pdf_processor/Cargo.toml new file mode 100644 index 000000000..02a6df79d --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/guest/pdf_processor/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "pdf_processor" +version = "0.1.0" +edition = "2021" +description = "WASI preview1 module for desktop pdf skill. Runs inside the wasmtime sandbox owned by cowork desktop runtime." +license = "MIT" + +[[bin]] +name = "pdf_processor" +path = "src/main.rs" + +[dependencies] +lopdf = { version = "0.36", default-features = false } +serde_json = { version = "1", default-features = false, features = ["std"] } + +[profile.release] +opt-level = "z" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/frontend/src-tauri/src/cowork/desktop_runtime/wasm/guest/pdf_processor/src/main.rs b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/guest/pdf_processor/src/main.rs new file mode 100644 index 000000000..6dc5fac04 --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/guest/pdf_processor/src/main.rs @@ -0,0 +1,199 @@ +//! pdf_processor — WASI preview1 module for the desktop `pdf` skill. +//! +//! Execution contract (matches `pdf.skill.md`): +//! +//! * input: +//! - `/workspace/input.json` — one of: +//! - `{ "op": "extract_text" }` +//! - `{ "op": "extract_text", "page_range": [start, end] }` +//! where `start` and `end` are 1-based **inclusive** page +//! numbers. If the range extends past the document, the module +//! clamps to the last page. If `start > end` or the range is +//! empty, the module returns `{"pages": []}` without error. +//! - `{ "op": "metadata" }` +//! - `/workspace/inputs/input.pdf` — the PDF to process +//! * output: +//! - `/workspace/output.json` — +//! - extract_text: `{ "pages": string[], "page_offset": n, "total_pages": m }` +//! `page_offset` is the 1-based page number of the first entry +//! in `pages`. `total_pages` is the document's full length. +//! Host-side chunking relies on both fields to merge chunks back +//! into document order. +//! - metadata: `{ "title": ..., "author": ..., "page_count": n }` +//! - stdout — a short human-readable line so the wasm_run tool result +//! has something useful even when output.json is also present. +//! * exit code 0 on success, 1 on any failure. +//! +//! The module is compiled for `wasm32-wasip1` and loaded by the cowork +//! desktop WASM runtime. It deliberately depends only on `lopdf` + the +//! standard library so the binary stays small. + +use std::fs; +use std::io::Write; + +use lopdf::Document; +use serde_json::{json, Value}; + +const INPUT_JSON: &str = "/workspace/input.json"; +const INPUT_PDF: &str = "/workspace/inputs/input.pdf"; +const OUTPUT_JSON: &str = "/workspace/output.json"; + +fn main() { + if let Err(error) = run() { + let _ = writeln!(std::io::stderr(), "pdf_processor error: {error}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), String> { + let input = read_input()?; + let document = Document::load(INPUT_PDF) + .map_err(|error| format!("failed to load {INPUT_PDF}: {error}"))?; + + let output = match input.op.as_str() { + "extract_text" => extract_text(&document, input.page_range.as_ref())?, + "metadata" => metadata(&document)?, + other => return Err(format!("unknown operation '{other}'")), + }; + + fs::write(OUTPUT_JSON, output.to_string()) + .map_err(|error| format!("failed to write {OUTPUT_JSON}: {error}"))?; + + println!("pdf_processor: op={} ok", input.op); + Ok(()) +} + +struct InputSpec { + op: String, + /// Optional 1-based inclusive page range. `None` means "all pages". + page_range: Option<(u32, u32)>, +} + +fn read_input() -> Result { + let bytes = fs::read(INPUT_JSON) + .map_err(|error| format!("failed to read {INPUT_JSON}: {error}"))?; + let value: Value = serde_json::from_slice(&bytes) + .map_err(|error| format!("failed to parse {INPUT_JSON}: {error}"))?; + let op = value + .get("op") + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| "input.json is missing the 'op' field".to_string())?; + + let page_range = match value.get("page_range") { + Some(Value::Null) | None => None, + Some(Value::Array(items)) => { + if items.len() != 2 { + return Err(format!( + "page_range must be a 2-element array [start, end], got {} elements", + items.len() + )); + } + let start = items[0] + .as_u64() + .ok_or_else(|| "page_range[0] must be a non-negative integer".to_string())?; + let end = items[1] + .as_u64() + .ok_or_else(|| "page_range[1] must be a non-negative integer".to_string())?; + Some((start as u32, end as u32)) + } + Some(other) => { + return Err(format!( + "page_range must be an array or null, got {}", + kind_of(other) + )); + } + }; + Ok(InputSpec { op, page_range }) +} + +fn kind_of(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "bool", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +fn extract_text(document: &Document, page_range: Option<&(u32, u32)>) -> Result { + let pages = document.get_pages(); + let mut ordered: Vec = pages.keys().copied().collect(); + ordered.sort_unstable(); + let total_pages = ordered.len() as u32; + + // Determine the slice of `ordered` to extract. + // + // The range is 1-based inclusive on both ends to match the contract + // in `pdf.skill.md`. We clamp both ends so the host-side chunker + // does not need to know the document length up front: it can fire + // [1, 50], [51, 100], ... and the last chunk will simply return + // fewer pages than requested when it runs off the end. + let (slice, page_offset) = if let Some(&(start, end)) = page_range { + if start == 0 { + return Err("page_range start must be 1 or greater".to_string()); + } + if start > end { + return Ok(json!({ + "pages": Vec::::new(), + "page_offset": start, + "total_pages": total_pages, + })); + } + let start_idx = (start - 1) as usize; + if start_idx >= ordered.len() { + return Ok(json!({ + "pages": Vec::::new(), + "page_offset": start, + "total_pages": total_pages, + })); + } + let end_idx = (end as usize).min(ordered.len()); + (&ordered[start_idx..end_idx], start) + } else { + (&ordered[..], 1u32) + }; + + let mut pages_out: Vec = Vec::with_capacity(slice.len()); + for &page_num in slice { + let text = document + .extract_text(&[page_num]) + .map_err(|error| format!("page {page_num} extraction failed: {error}"))?; + pages_out.push(text); + } + Ok(json!({ + "pages": pages_out, + "page_offset": page_offset, + "total_pages": total_pages, + })) +} + +fn metadata(document: &Document) -> Result { + let page_count = document.get_pages().len(); + let info = document.trailer.get(b"Info").ok(); + let mut title: Option = None; + let mut author: Option = None; + if let Some(info_ref) = info { + if let Ok(object_id) = info_ref.as_reference() { + if let Ok(info_dict) = document.get_object(object_id).and_then(|o| o.as_dict()) { + title = read_text_field(info_dict, b"Title"); + author = read_text_field(info_dict, b"Author"); + } + } + } + Ok(json!({ + "title": title, + "author": author, + "page_count": page_count, + })) +} + +fn read_text_field(dict: &lopdf::Dictionary, key: &[u8]) -> Option { + let object = dict.get(key).ok()?; + if let Ok(bytes) = object.as_str() { + return Some(String::from_utf8_lossy(bytes).into_owned()); + } + None +} diff --git a/frontend/src-tauri/src/cowork/desktop_runtime/wasm/guest/pptx_processor/Cargo.toml b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/guest/pptx_processor/Cargo.toml new file mode 100644 index 000000000..73cd4c84a --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/guest/pptx_processor/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "pptx_processor" +version = "0.1.0" +edition = "2021" +description = "WASI preview1 module for the desktop pptx skill. Extracts slide text from PowerPoint containers using zip + quick-xml." +license = "MIT" + +[[bin]] +name = "pptx_processor" +path = "src/main.rs" + +[dependencies] +zip = { version = "2", default-features = false, features = ["deflate"] } +quick-xml = { version = "0.36", default-features = false } +serde_json = { version = "1", default-features = false, features = ["std"] } + +[profile.release] +opt-level = "z" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/frontend/src-tauri/src/cowork/desktop_runtime/wasm/guest/pptx_processor/src/main.rs b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/guest/pptx_processor/src/main.rs new file mode 100644 index 000000000..09aa9218f --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/guest/pptx_processor/src/main.rs @@ -0,0 +1,262 @@ +//! pptx_processor — WASI preview1 module for the desktop `pptx` skill. +//! +//! Execution contract (matches `pptx.skill.md`): +//! +//! * input: +//! - `/workspace/input.json` — one of: +//! - `{ "op": "extract_text" }` +//! - `{ "op": "extract_text", "slide_range": [start, end] }` +//! - `{ "op": "count_slides" }` +//! - `/workspace/inputs/input.pptx` — the presentation to process +//! * output: +//! - `/workspace/output.json`: +//! extract_text: `{ "slides": [{"index": n, "text": string}], "slide_offset": n, "total_slides": m }` +//! count_slides: `{ "total_slides": n }` +//! - stdout — short "ok" line +//! * exit code 0 on success, 1 on any failure. +//! +//! pptx is an OOXML zip container. Slide text lives in +//! `ppt/slides/slideN.xml` where N is a 1-based index. For v1 we: +//! +//! 1. Enumerate every `ppt/slides/slide*.xml` entry in the zip. +//! 2. Sort them by numeric index (slide1, slide2, ... slide10). +//! 3. For each slide in scope, parse the XML and concatenate every +//! `` text run into a single string. +//! +//! This is the minimum viable slide text extraction. It intentionally +//! ignores speaker notes (`ppt/notesSlides/...`), comments, and layout +//! metadata — those are future ops. + +use std::fs; +use std::io::{Cursor, Write}; + +use quick_xml::events::Event; +use quick_xml::reader::Reader; +use serde_json::{json, Value}; +use zip::ZipArchive; + +const INPUT_JSON: &str = "/workspace/input.json"; +const INPUT_PPTX: &str = "/workspace/inputs/input.pptx"; +const OUTPUT_JSON: &str = "/workspace/output.json"; + +fn main() { + if let Err(error) = run() { + let _ = writeln!(std::io::stderr(), "pptx_processor error: {error}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), String> { + let input = read_input()?; + let bytes = fs::read(INPUT_PPTX) + .map_err(|error| format!("failed to read {INPUT_PPTX}: {error}"))?; + let mut archive = ZipArchive::new(Cursor::new(bytes)) + .map_err(|error| format!("pptx zip open failed: {error}"))?; + + let slides = collect_slide_entries(&mut archive)?; + + let output = match input.op.as_str() { + "extract_text" => extract_text(&mut archive, &slides, input.slide_range.as_ref())?, + "count_slides" => json!({ "total_slides": slides.len() as u64 }), + other => return Err(format!("unknown operation '{other}'")), + }; + + fs::write(OUTPUT_JSON, output.to_string()) + .map_err(|error| format!("failed to write {OUTPUT_JSON}: {error}"))?; + println!("pptx_processor: op={} ok", input.op); + Ok(()) +} + +struct InputSpec { + op: String, + slide_range: Option<(u32, u32)>, +} + +fn read_input() -> Result { + let bytes = fs::read(INPUT_JSON) + .map_err(|error| format!("failed to read {INPUT_JSON}: {error}"))?; + let value: Value = serde_json::from_slice(&bytes) + .map_err(|error| format!("failed to parse {INPUT_JSON}: {error}"))?; + let op = value + .get("op") + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| "input.json is missing 'op'".to_string())?; + let slide_range = parse_range(&value, "slide_range")?; + Ok(InputSpec { op, slide_range }) +} + +fn parse_range(value: &Value, key: &str) -> Result, String> { + match value.get(key) { + Some(Value::Null) | None => Ok(None), + Some(Value::Array(items)) => { + if items.len() != 2 { + return Err(format!( + "{key} must be a 2-element array, got {}", + items.len() + )); + } + let start = items[0] + .as_u64() + .ok_or_else(|| format!("{key}[0] must be a non-negative integer"))?; + let end = items[1] + .as_u64() + .ok_or_else(|| format!("{key}[1] must be a non-negative integer"))?; + Ok(Some((start as u32, end as u32))) + } + Some(other) => Err(format!("{key} must be array or null, got {other:?}")), + } +} + +/// One zip entry representing a slide, pre-sorted by 1-based slide +/// number. +struct SlideEntry { + /// 1-based slide index (`ppt/slides/slide1.xml` → 1). + index: u32, + /// Path inside the archive, used to `by_name` lookup the raw bytes + /// at extraction time. + path: String, +} + +fn collect_slide_entries( + archive: &mut ZipArchive, +) -> Result, String> { + let mut entries: Vec = Vec::new(); + for index in 0..archive.len() { + let file = archive + .by_index(index) + .map_err(|error| format!("pptx archive read failed: {error}"))?; + let name = file.name().to_string(); + if let Some(slide_index) = parse_slide_path(&name) { + entries.push(SlideEntry { + index: slide_index, + path: name, + }); + } + } + entries.sort_by_key(|entry| entry.index); + Ok(entries) +} + +/// Match `ppt/slides/slideN.xml` (case-sensitive, the OOXML spec fixes +/// the casing). Returns the 1-based slide number on match. +fn parse_slide_path(path: &str) -> Option { + let file_name = path.strip_prefix("ppt/slides/")?; + if !file_name.ends_with(".xml") { + return None; + } + let stem = &file_name[..file_name.len() - 4]; + let number = stem.strip_prefix("slide")?; + // Reject `slideLayoutN.xml`, `slideMasterN.xml`, etc. + if !number.chars().all(|c| c.is_ascii_digit()) { + return None; + } + number.parse().ok() +} + +fn extract_text( + archive: &mut ZipArchive, + entries: &[SlideEntry], + range: Option<&(u32, u32)>, +) -> Result { + let total = entries.len() as u32; + let (slice, offset) = if let Some(&(start, end)) = range { + if start == 0 { + return Err("slide_range start must be 1 or greater".to_string()); + } + if start > end { + return Ok(json!({ + "slides": Vec::::new(), + "slide_offset": start, + "total_slides": total, + })); + } + let start_idx = (start - 1) as usize; + if start_idx >= entries.len() { + return Ok(json!({ + "slides": Vec::::new(), + "slide_offset": start, + "total_slides": total, + })); + } + let end_idx = (end as usize).min(entries.len()); + (&entries[start_idx..end_idx], start) + } else { + (entries, 1u32) + }; + + let mut slides_out: Vec = Vec::with_capacity(slice.len()); + for entry in slice { + let mut zip_file = archive + .by_name(&entry.path) + .map_err(|error| format!("pptx: cannot read {}: {}", entry.path, error))?; + let mut xml_bytes = Vec::new(); + std::io::copy(&mut zip_file, &mut xml_bytes) + .map_err(|error| format!("pptx: zip read failed: {error}"))?; + let text = extract_slide_text(&xml_bytes)?; + slides_out.push(json!({ + "index": entry.index, + "text": text, + })); + } + + Ok(json!({ + "slides": slides_out, + "slide_offset": offset, + "total_slides": total, + })) +} + +/// Walk a slide XML document and return every `` run concatenated. +/// Text inside different paragraphs is joined with a single newline so +/// the agent can reconstruct reading order without parsing structure. +fn extract_slide_text(xml_bytes: &[u8]) -> Result { + let mut reader = Reader::from_reader(xml_bytes); + reader.config_mut().trim_text(true); + let mut in_text = false; + let mut in_paragraph = false; + let mut current_paragraph = String::new(); + let mut paragraphs: Vec = Vec::new(); + let mut buf = Vec::new(); + + loop { + match reader + .read_event_into(&mut buf) + .map_err(|error| format!("pptx xml parse error: {error}"))? + { + Event::Start(e) => { + let name = e.name(); + let local = name.as_ref(); + if local.ends_with(b":t") || local == b"t" { + in_text = true; + } else if local.ends_with(b":p") || local == b"p" { + in_paragraph = true; + current_paragraph.clear(); + } + } + Event::End(e) => { + let name = e.name(); + let local = name.as_ref(); + if local.ends_with(b":t") || local == b"t" { + in_text = false; + } else if local.ends_with(b":p") || local == b"p" { + in_paragraph = false; + if !current_paragraph.is_empty() { + paragraphs.push(current_paragraph.clone()); + } + } + } + Event::Text(t) if in_text && in_paragraph => { + let decoded = t + .unescape() + .map_err(|error| format!("pptx xml unescape error: {error}"))?; + current_paragraph.push_str(&decoded); + } + Event::Eof => break, + _ => {} + } + buf.clear(); + } + + Ok(paragraphs.join("\n")) +} diff --git a/frontend/src-tauri/src/cowork/desktop_runtime/wasm/guest/xlsx_processor/Cargo.toml b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/guest/xlsx_processor/Cargo.toml new file mode 100644 index 000000000..e7de55316 --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/guest/xlsx_processor/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "xlsx_processor" +version = "0.1.0" +edition = "2021" +description = "WASI preview1 module for the desktop xlsx skill. Reads spreadsheet workbooks with calamine inside the cowork WASM runtime." +license = "MIT" + +[[bin]] +name = "xlsx_processor" +path = "src/main.rs" + +[dependencies] +calamine = { version = "0.26", default-features = false } +serde_json = { version = "1", default-features = false, features = ["std"] } + +[profile.release] +opt-level = "z" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/frontend/src-tauri/src/cowork/desktop_runtime/wasm/guest/xlsx_processor/src/main.rs b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/guest/xlsx_processor/src/main.rs new file mode 100644 index 000000000..f284cff5c --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/guest/xlsx_processor/src/main.rs @@ -0,0 +1,204 @@ +//! xlsx_processor — WASI preview1 module for the desktop `xlsx` skill. +//! +//! Execution contract (matches `xlsx.skill.md`): +//! +//! * input: +//! - `/workspace/input.json` — one of: +//! - `{ "op": "list_sheets" }` +//! - `{ "op": "read_sheet", "sheet": "" }` +//! - `{ "op": "read_sheet", "sheet": "", "row_range": [start, end] }` +//! 1-based inclusive row numbers. Out-of-range requests clamp +//! to the sheet's used range. +//! - `/workspace/inputs/input.xlsx` — the workbook to process +//! * output: +//! - `/workspace/output.json`: +//! list_sheets: `{ "sheets": [{"name": string, "rows": n, "cols": m}] }` +//! read_sheet: `{ "sheet": string, "rows": Value[][], "row_offset": n, "total_rows": m }` +//! - stdout — short "ok" line +//! * exit code 0 on success, 1 on any failure. +//! +//! calamine is a pure-Rust xlsx/xls/ods reader. It compiles cleanly to +//! `wasm32-wasip1` with `default-features = false`. Writing workbooks +//! back is not supported in v1 because it would pull in a separate +//! writer crate; write-back is a future skill op. + +use std::io::{Cursor, Write}; +use std::{fs, io}; + +use calamine::{open_workbook_auto_from_rs, Data, Reader}; +use serde_json::{json, Value}; + +const INPUT_JSON: &str = "/workspace/input.json"; +const INPUT_XLSX: &str = "/workspace/inputs/input.xlsx"; +const OUTPUT_JSON: &str = "/workspace/output.json"; + +fn main() { + if let Err(error) = run() { + let _ = writeln!(io::stderr(), "xlsx_processor error: {error}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), String> { + let input = read_input()?; + // calamine's `open_workbook_auto_from_rs` requires `Read + Seek + + // Clone`. `Cursor>` satisfies all three (BufReader does + // not — BufReader is not Clone), so we pass the cursor directly. + let bytes = fs::read(INPUT_XLSX) + .map_err(|error| format!("failed to read {INPUT_XLSX}: {error}"))?; + let cursor = Cursor::new(bytes); + let mut workbook = open_workbook_auto_from_rs(cursor) + .map_err(|error| format!("xlsx open failed: {error}"))?; + + let output = match input.op.as_str() { + "list_sheets" => list_sheets(&mut workbook)?, + "read_sheet" => read_sheet( + &mut workbook, + input + .sheet + .as_deref() + .ok_or_else(|| "read_sheet requires a 'sheet' name".to_string())?, + input.row_range.as_ref(), + )?, + other => return Err(format!("unknown operation '{other}'")), + }; + + fs::write(OUTPUT_JSON, output.to_string()) + .map_err(|error| format!("failed to write {OUTPUT_JSON}: {error}"))?; + println!("xlsx_processor: op={} ok", input.op); + Ok(()) +} + +struct InputSpec { + op: String, + sheet: Option, + row_range: Option<(u32, u32)>, +} + +fn read_input() -> Result { + let bytes = fs::read(INPUT_JSON) + .map_err(|error| format!("failed to read {INPUT_JSON}: {error}"))?; + let value: Value = serde_json::from_slice(&bytes) + .map_err(|error| format!("failed to parse {INPUT_JSON}: {error}"))?; + let op = value + .get("op") + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| "input.json is missing 'op'".to_string())?; + let sheet = value + .get("sheet") + .and_then(Value::as_str) + .map(str::to_string); + let row_range = parse_range(&value, "row_range")?; + Ok(InputSpec { + op, + sheet, + row_range, + }) +} + +fn parse_range(value: &Value, key: &str) -> Result, String> { + match value.get(key) { + Some(Value::Null) | None => Ok(None), + Some(Value::Array(items)) => { + if items.len() != 2 { + return Err(format!( + "{key} must be a 2-element array, got {}", + items.len() + )); + } + let start = items[0] + .as_u64() + .ok_or_else(|| format!("{key}[0] must be a non-negative integer"))?; + let end = items[1] + .as_u64() + .ok_or_else(|| format!("{key}[1] must be a non-negative integer"))?; + Ok(Some((start as u32, end as u32))) + } + Some(other) => Err(format!("{key} must be array or null, got {other:?}")), + } +} + +type Workbook = calamine::Sheets>>; + +fn list_sheets(workbook: &mut Workbook) -> Result { + let names: Vec = workbook.sheet_names().to_vec(); + let mut sheets = Vec::with_capacity(names.len()); + for name in names { + let range = workbook + .worksheet_range(&name) + .map_err(|error| format!("worksheet_range({name}) failed: {error}"))?; + let rows = range.height() as u64; + let cols = range.width() as u64; + sheets.push(json!({ + "name": name, + "rows": rows, + "cols": cols, + })); + } + Ok(json!({ "sheets": sheets })) +} + +fn read_sheet( + workbook: &mut Workbook, + sheet_name: &str, + range_hint: Option<&(u32, u32)>, +) -> Result { + let range = workbook + .worksheet_range(sheet_name) + .map_err(|error| format!("worksheet_range({sheet_name}) failed: {error}"))?; + let total_rows = range.height() as u32; + + // Materialise the sheet into rows-of-cells. + let mut rows: Vec> = Vec::with_capacity(range.height()); + for row in range.rows() { + let mut cells = Vec::with_capacity(row.len()); + for cell in row { + cells.push(cell_to_json(cell)); + } + rows.push(cells); + } + + let (slice, offset): (Vec>, u32) = if let Some(&(start, end)) = range_hint { + if start == 0 { + return Err("row_range start must be 1 or greater".to_string()); + } + if start > end || start as usize > rows.len() { + (Vec::new(), start) + } else { + let start_idx = (start - 1) as usize; + let end_idx = (end as usize).min(rows.len()); + (rows[start_idx..end_idx].to_vec(), start) + } + } else { + (rows, 1u32) + }; + + Ok(json!({ + "sheet": sheet_name, + "rows": slice, + "row_offset": offset, + "total_rows": total_rows, + })) +} + +/// Convert a calamine cell into a JSON value. We avoid using +/// `serde_json::to_value(&Data)` because calamine's `Data` enum uses +/// a serde tag format that is unhelpful for the LLM to read; we flatten +/// to plain JSON primitives instead. +fn cell_to_json(cell: &Data) -> Value { + match cell { + Data::Empty => Value::Null, + Data::String(s) => Value::String(s.clone()), + Data::Float(f) => serde_json::Number::from_f64(*f) + .map(Value::Number) + .unwrap_or(Value::Null), + Data::Int(i) => Value::from(*i), + Data::Bool(b) => Value::Bool(*b), + Data::DateTime(dt) => Value::String(dt.as_f64().to_string()), + Data::Error(e) => Value::String(format!("#ERROR: {e:?}")), + Data::DurationIso(s) => Value::String(s.clone()), + Data::DateTimeIso(s) => Value::String(s.clone()), + } +} + diff --git a/frontend/src-tauri/src/cowork/desktop_runtime/wasm/lifecycle.rs b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/lifecycle.rs new file mode 100644 index 000000000..3833e12cd --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/lifecycle.rs @@ -0,0 +1,248 @@ +//! Lifecycle management for the shared desktop [`WasmRuntime`]. +//! +//! The engine + registry that back every `wasm_run` call are expensive +//! to set up (compile all built-in modules through cranelift) and +//! reasonably cheap to keep around idle, but we still want them dropped +//! eventually so an unused desktop session does not hold on to +//! ~5–10 MB of wasmtime state forever. This module implements that +//! policy: +//! +//! * **Lazy creation.** The runtime is `None` until the first caller +//! asks for it. At that point we build a fresh [`WasmRuntime`], +//! register the built-in modules, and record `last_used = now`. +//! * **In-flight counting.** Each caller leases the runtime via +//! [`acquire_runtime`] which returns a [`RuntimeLease`] guard. The +//! guard increments an in-flight counter on acquisition and +//! decrements it when dropped. A lease holder keeps the runtime alive +//! no matter how idle the app has been. +//! * **Idle reaper.** A single background thread wakes up on a fixed +//! interval (see [`REAP_INTERVAL`]). When `in_flight == 0` and +//! `now - last_used > IDLE_TIMEOUT`, it drops the runtime. The next +//! caller will rebuild it lazily. +//! +//! The reaper never drops a runtime that has active leases — `Drop` on +//! [`RuntimeLease`] only *refreshes* `last_used`; it does not itself +//! trigger teardown. This means a long-running call (pdf extraction for +//! a large document) cannot have the engine pulled out from under it by +//! the reaper half-way through. + +use super::WasmRuntime; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; +use std::thread; +use std::time::{Duration, Instant}; + +/// How long the runtime may stay idle before the reaper drops it. +/// +/// Exposed as a module-level constant rather than a config value +/// because it is not something the LLM or the user should tune — it is +/// a direct trade-off between "keep lazy init latency away from the +/// user" and "do not hold RAM forever". Five minutes splits the +/// difference for typical cowork sessions. +pub const IDLE_TIMEOUT: Duration = Duration::from_secs(5 * 60); + +/// How often the background reaper wakes up to check whether the +/// runtime should be torn down. Shorter means faster release after +/// idle; longer means less wake-up noise. 30 s is short enough to drop +/// within a minute of the timeout firing and long enough that the +/// reaper thread does not show up in CPU profiles. +pub const REAP_INTERVAL: Duration = Duration::from_secs(30); + +/// Shared mutable state watched by the reaper. Guarded by a single +/// `Mutex` because every mutation (acquire, release, reap) is short +/// and non-blocking. +struct LifecycleState { + runtime: Option>, + last_used: Instant, + in_flight: u32, + /// Timeout applied by the reaper. Exposed as state (rather than a + /// constant) so tests can drive the reaper without waiting five + /// minutes of real time. + idle_timeout: Duration, +} + +impl LifecycleState { + fn new(idle_timeout: Duration) -> Self { + Self { + runtime: None, + last_used: Instant::now(), + in_flight: 0, + idle_timeout, + } + } +} + +/// Lazy container holding the global lifecycle state + the handle to +/// the reaper thread. The first [`acquire_runtime`] call constructs the +/// `Arc>` and spawns the reaper; subsequent calls just clone +/// the `Arc`. +static GLOBAL: OnceLock>> = OnceLock::new(); + +fn global_state() -> Arc> { + GLOBAL + .get_or_init(|| { + let state = Arc::new(Mutex::new(LifecycleState::new(IDLE_TIMEOUT))); + spawn_reaper(Arc::clone(&state), REAP_INTERVAL); + state + }) + .clone() +} + +fn spawn_reaper(state: Arc>, interval: Duration) { + thread::spawn(move || loop { + thread::sleep(interval); + reap_once(&state); + }); +} + +/// Drop the runtime if it has been idle for longer than the configured +/// timeout **and** no leases are outstanding. Factored out so tests can +/// drive it synchronously. +fn reap_once(state: &Arc>) -> bool { + let mut guard = lock_or_recover(state); + if guard.runtime.is_none() { + return false; + } + if guard.in_flight > 0 { + return false; + } + if guard.last_used.elapsed() < guard.idle_timeout { + return false; + } + guard.runtime = None; + true +} + +/// Acquire a lease on the global desktop WASM runtime. +/// +/// Lazy-creates the runtime on first use. The returned [`RuntimeLease`] +/// keeps the runtime alive while it exists; drop it when the caller is +/// finished to let the idle reaper reclaim the engine eventually. +pub fn acquire_runtime() -> Result { + let state = global_state(); + let runtime_arc = { + let mut guard = lock_or_recover(&state); + if guard.runtime.is_none() { + let new_runtime = WasmRuntime::new()?; + guard.runtime = Some(Arc::new(new_runtime)); + } + guard.in_flight = guard.in_flight.saturating_add(1); + guard.last_used = Instant::now(); + Arc::clone(guard.runtime.as_ref().unwrap_or_else(|| unreachable!())) + }; + Ok(RuntimeLease { + runtime: runtime_arc, + state, + }) +} + +/// RAII guard returned by [`acquire_runtime`]. Holds an `Arc` to the +/// runtime and to the lifecycle state so `Drop` can decrement the +/// in-flight counter and refresh `last_used`. +pub struct RuntimeLease { + runtime: Arc, + state: Arc>, +} + +impl RuntimeLease { + pub fn runtime(&self) -> &Arc { + &self.runtime + } +} + +impl std::ops::Deref for RuntimeLease { + type Target = WasmRuntime; + fn deref(&self) -> &Self::Target { + &self.runtime + } +} + +impl Drop for RuntimeLease { + fn drop(&mut self) { + let mut guard = lock_or_recover(&self.state); + guard.in_flight = guard.in_flight.saturating_sub(1); + guard.last_used = Instant::now(); + } +} + +fn lock_or_recover(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Test helper — build an isolated lifecycle state with a tunable + /// timeout so we can drive the reaper without waiting five minutes. + fn isolated_state(timeout: Duration) -> Arc> { + Arc::new(Mutex::new(LifecycleState::new(timeout))) + } + + fn lease( + state: &Arc>, + ) -> Result { + let mut guard = state.lock().unwrap(); + if guard.runtime.is_none() { + guard.runtime = Some(Arc::new(WasmRuntime::new()?)); + } + guard.in_flight = guard.in_flight.saturating_add(1); + guard.last_used = Instant::now(); + let runtime = Arc::clone(guard.runtime.as_ref().unwrap()); + drop(guard); + Ok(RuntimeLease { + runtime, + state: Arc::clone(state), + }) + } + + #[test] + fn reaper_drops_idle_runtime() { + let state = isolated_state(Duration::from_millis(50)); + + // First call creates the runtime. + let first_lease = lease(&state).expect("runtime builds"); + assert!(state.lock().unwrap().runtime.is_some()); + drop(first_lease); + + // Still within the timeout window. + assert!(!reap_once(&state), "should not reap while still fresh"); + + // Past the (tiny) idle timeout. + std::thread::sleep(Duration::from_millis(80)); + assert!(reap_once(&state), "should reap after idle timeout"); + assert!(state.lock().unwrap().runtime.is_none()); + } + + #[test] + fn reaper_respects_in_flight_lease() { + let state = isolated_state(Duration::from_millis(10)); + + let _hold = lease(&state).expect("runtime builds"); + std::thread::sleep(Duration::from_millis(50)); + + // In-flight = 1, reaper must leave the runtime alone even though + // the idle window has clearly elapsed. + assert!(!reap_once(&state), "should never reap while leased"); + assert!(state.lock().unwrap().runtime.is_some()); + } + + #[test] + fn lazy_creation_is_observable() { + let state = isolated_state(Duration::from_secs(60)); + assert!(state.lock().unwrap().runtime.is_none()); + + let lease1 = lease(&state).expect("runtime builds"); + assert!(state.lock().unwrap().runtime.is_some()); + assert_eq!(state.lock().unwrap().in_flight, 1); + + let lease2 = lease(&state).expect("runtime reused"); + assert_eq!(state.lock().unwrap().in_flight, 2); + + drop(lease1); + assert_eq!(state.lock().unwrap().in_flight, 1); + drop(lease2); + assert_eq!(state.lock().unwrap().in_flight, 0); + } +} diff --git a/frontend/src-tauri/src/cowork/desktop_runtime/wasm/mod.rs b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/mod.rs new file mode 100644 index 000000000..1d5da8caa --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/mod.rs @@ -0,0 +1,1146 @@ +//! Isolated WASM runtime backend. +//! +//! This module owns the sandboxed execution path used by skill-driven +//! processing tasks that should not run directly on the host. It is the +//! single place where the wasmtime engine, WASI preview1 linker, resource +//! limits, temporary workspace layout, and the input/output contract with +//! callers are defined. +//! +//! ## Execution contract +//! +//! A caller supplies: +//! +//! 1. A WASM module name. The runtime resolves the module via +//! [`ModuleRegistry`] (either an embedded module shipped with the +//! desktop app or, for tests, a raw byte buffer). +//! 2. A JSON input payload. The runtime serialises this to a temp file +//! inside a per-call scratch directory and exposes the scratch directory +//! to the guest as its preopened `/workspace` directory via WASI. +//! 3. Optional host files to mirror into the scratch `/workspace/inputs` +//! directory. +//! 4. A [`RuntimeLimits`] override. Defaults come from +//! [`RuntimeLimits::defaults`]. +//! +//! The runtime returns a [`WasmRunResult`] containing stdout, stderr, +//! structured JSON output read from `/workspace/output.json` if the guest +//! produced one, a list of output artifact paths copied back from +//! `/workspace/outputs`, and the wall-clock duration. Errors are returned +//! as [`WasmRunError`] variants. +//! +//! ## Sandboxing +//! +//! * A fresh `wasmtime::Store` is created per call (no state bleeding). +//! * Fuel is metered; the store traps when `RuntimeLimits::max_fuel` is +//! consumed. +//! * Epoch-based deadlines enforce `wall_timeout` even when the guest is +//! stuck in non-fuel-accounted code. +//! * Linear memory is capped by [`wasmtime::ResourceLimiter`]. +//! * File access is restricted to the preopened scratch directory via +//! WASI. The guest cannot touch the host filesystem outside of it. +//! +//! ## Temp workspace layout +//! +//! Every call creates a scratch directory like: +//! +//! ```text +//! {root}/wasm-{session_id}/{call_id}/ +//! input.json (caller payload) +//! inputs/ (mirrored host files — optional) +//! outputs/ (populated by the guest) +//! output.json (optional structured guest response) +//! ``` +//! +//! The caller is responsible for providing `root`. The scratch directory +//! is removed when the [`WasmRuntimeCall`] guard is dropped, unless the +//! caller asks the runtime to keep it for debugging. + +#![allow(dead_code)] + +pub mod chunking; +pub mod freshness; +pub mod lifecycle; + +pub use lifecycle::acquire_runtime; + +use super::{RuntimeKind, RuntimeLimits}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::any::Any; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::{Duration, Instant}; +use std::{io, thread}; +use wasmtime::{Config, Engine, Linker, Module, Store, StoreLimits, StoreLimitsBuilder}; +use wasmtime_wasi::preview1::{self as wasi_preview1, WasiP1Ctx}; +use wasmtime_wasi::{DirPerms, FilePerms, WasiCtxBuilder}; + +/// Registry of WASM modules known to the desktop runtime. +/// +/// Modules can be registered ahead of time (application-owned assets) or +/// at runtime (for tests and experimental skills). Lookup by name is +/// case-sensitive. +#[derive(Default)] +pub struct ModuleRegistry { + entries: Mutex>, +} + +#[derive(Clone)] +struct ModuleEntry { + source: ModuleSource, +} + +#[derive(Clone)] +enum ModuleSource { + Bytes(Arc>), + Wat(Arc), + Path(PathBuf), +} + +impl ModuleRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Register a module by its raw WASM bytes. The byte buffer is stored + /// behind an `Arc` so repeated lookups are cheap. + pub fn register_bytes(&self, name: impl Into, bytes: Vec) { + lock_or_recover(&self.entries).insert( + name.into(), + ModuleEntry { + source: ModuleSource::Bytes(Arc::new(bytes)), + }, + ); + } + + /// Register a module by its WAT (text format) source. Useful for + /// bundling small helpers without having to ship pre-compiled bytes. + pub fn register_wat(&self, name: impl Into, wat: impl Into) { + lock_or_recover(&self.entries).insert( + name.into(), + ModuleEntry { + source: ModuleSource::Wat(Arc::new(wat.into())), + }, + ); + } + + /// Register a module that lives on disk. The path is resolved lazily, + /// so it is safe to register modules that will only become available + /// at runtime. + pub fn register_path(&self, name: impl Into, path: PathBuf) { + lock_or_recover(&self.entries).insert( + name.into(), + ModuleEntry { + source: ModuleSource::Path(path), + }, + ); + } + + pub fn has(&self, name: &str) -> bool { + lock_or_recover(&self.entries).contains_key(name) + } + + pub fn names(&self) -> Vec { + let mut names: Vec = lock_or_recover(&self.entries).keys().cloned().collect(); + names.sort(); + names + } + + fn lookup(&self, name: &str) -> Option { + lock_or_recover(&self.entries).get(name).cloned() + } + + fn load_module(&self, engine: &Engine, name: &str) -> Result { + let entry = self + .lookup(name) + .ok_or_else(|| WasmRunError::UnknownModule(name.to_string()))?; + match entry.source { + ModuleSource::Bytes(bytes) => Module::new(engine, bytes.as_slice()) + .map_err(|error| WasmRunError::ModuleLoad(error.to_string())), + ModuleSource::Wat(wat) => { + let bytes = wat::parse_str(wat.as_str()) + .map_err(|error| WasmRunError::ModuleLoad(error.to_string()))?; + Module::new(engine, bytes.as_slice()) + .map_err(|error| WasmRunError::ModuleLoad(error.to_string())) + } + ModuleSource::Path(path) => { + let bytes = fs::read(&path).map_err(|error| { + WasmRunError::ModuleLoad(format!( + "failed to read WASM module at {}: {}", + path.display(), + error + )) + })?; + Module::new(engine, bytes.as_slice()) + .map_err(|error| WasmRunError::ModuleLoad(error.to_string())) + } + } + } +} + +/// Input passed to a single [`WasmRuntime::run`] call. +#[derive(Debug, Clone)] +pub struct WasmRunRequest { + pub module_name: String, + /// Optional JSON payload written to `input.json` in the scratch + /// directory before the guest runs. + pub input_json: Option, + /// Optional host files mirrored into `inputs/` (source path → logical + /// filename inside `inputs/`). + pub input_files: Vec<(PathBuf, String)>, + /// Optional resource limit overrides. Missing fields fall back to + /// [`RuntimeLimits::defaults`]. + pub limits: Option, + /// Keep the scratch workspace on disk after the call returns. Useful + /// for debugging; defaults to `false` so artifacts are cleaned up. + pub keep_workspace: bool, + /// Optional entrypoint function to invoke. Defaults to calling the + /// module's `_start` (WASI command) export. + pub entrypoint: WasmEntrypoint, + /// Optional cowork session identifier. When set, the runtime scopes + /// the scratch directory under `{scratch_root}/{session_id}/` so + /// session cleanup can sweep only the directories belonging to a + /// given session. + pub session_id: Option, +} + +impl WasmRunRequest { + pub fn new(module_name: impl Into) -> Self { + Self { + module_name: module_name.into(), + input_json: None, + input_files: Vec::new(), + limits: None, + keep_workspace: false, + entrypoint: WasmEntrypoint::Start, + session_id: None, + } + } + + pub fn with_input_json(mut self, value: Value) -> Self { + self.input_json = Some(value); + self + } + + pub fn with_session_id(mut self, session_id: impl Into) -> Self { + self.session_id = Some(session_id.into()); + self + } +} + +/// Guest entrypoint strategy. +#[derive(Debug, Clone)] +pub enum WasmEntrypoint { + /// Call the module's `_start` export (standard WASI command). + Start, + /// Call a named exported function that takes and returns no + /// parameters. Used by simple utility modules that are not full WASI + /// commands. + NamedVoid(String), +} + +/// Structured result produced by a WASM call. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WasmRunResult { + pub module: String, + pub stdout: String, + pub stderr: String, + pub duration_ms: u128, + pub output_json: Option, + pub output_files: Vec, + /// If `keep_workspace` was set, this is the scratch directory on the + /// host. Otherwise `None`. + pub scratch_dir: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WasmOutputArtifact { + pub name: String, + pub bytes: usize, +} + +/// Typed errors returned by the WASM runtime. +#[derive(Debug)] +pub enum WasmRunError { + /// The module name did not resolve in the registry. + UnknownModule(String), + /// Compilation or bytecode loading failed. + ModuleLoad(String), + /// Host-side I/O error preparing or tearing down the scratch dir. + Io(String), + /// Guest execution trapped or exceeded a limit. + Execution(String), + /// Wall-clock deadline exceeded before the guest returned. + Timeout(Duration), + /// Fuel limit exhausted before the guest returned. + OutOfFuel, + /// Linear memory limit exceeded. + MemoryLimit(usize), +} + +impl std::fmt::Display for WasmRunError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + WasmRunError::UnknownModule(name) => { + write!(f, "unknown WASM module '{}'", name) + } + WasmRunError::ModuleLoad(detail) => { + write!(f, "failed to load WASM module: {}", detail) + } + WasmRunError::Io(detail) => write!(f, "WASM runtime I/O error: {}", detail), + WasmRunError::Execution(detail) => { + write!(f, "WASM guest execution failed: {}", detail) + } + WasmRunError::Timeout(duration) => { + write!(f, "WASM call exceeded wall timeout {:?}", duration) + } + WasmRunError::OutOfFuel => write!(f, "WASM call exhausted fuel budget"), + WasmRunError::MemoryLimit(limit) => { + write!(f, "WASM call exceeded memory limit {} bytes", limit) + } + } + } +} + +impl std::error::Error for WasmRunError {} + +impl From for WasmRunError { + fn from(error: io::Error) -> Self { + WasmRunError::Io(error.to_string()) + } +} + +/// The desktop WASM runtime. A single instance owns a shared wasmtime +/// engine and the module registry; it is cheap to clone via `Arc`. +pub struct WasmRuntime { + engine: Engine, + registry: ModuleRegistry, + scratch_root: Mutex>, +} + +impl WasmRuntime { + /// Construct a new runtime with sensible defaults and register the + /// built-in WASM modules that ship with the desktop app. + pub fn new() -> Result { + let mut config = Config::new(); + config.consume_fuel(true); + config.epoch_interruption(true); + config.wasm_backtrace(true); + config.wasm_bulk_memory(true); + config.wasm_multi_value(true); + let engine = + Engine::new(&config).map_err(|error| WasmRunError::ModuleLoad(error.to_string()))?; + let runtime = Self { + engine, + registry: ModuleRegistry::new(), + scratch_root: Mutex::new(None), + }; + runtime.register_builtin_modules(); + Ok(runtime) + } + + /// Register every WASM module that is bundled into the desktop + /// binary via `include_bytes!`. Called from [`Self::new`]; callers + /// should not need to invoke this directly. + /// + /// Built-in modules are keyed by their skill-contract name (the same + /// string that appears in each skill's frontmatter `wasm_module` + /// field). Skills look up their module by this name when dispatching + /// a call through `desktop_skill_run`. + fn register_builtin_modules(&self) { + const PDF_PROCESSOR: &[u8] = include_bytes!("modules/pdf_processor.wasm"); + const DOCX_PROCESSOR: &[u8] = include_bytes!("modules/docx_processor.wasm"); + const XLSX_PROCESSOR: &[u8] = include_bytes!("modules/xlsx_processor.wasm"); + const PPTX_PROCESSOR: &[u8] = include_bytes!("modules/pptx_processor.wasm"); + self.registry + .register_bytes("pdf_processor", PDF_PROCESSOR.to_vec()); + self.registry + .register_bytes("docx_processor", DOCX_PROCESSOR.to_vec()); + self.registry + .register_bytes("xlsx_processor", XLSX_PROCESSOR.to_vec()); + self.registry + .register_bytes("pptx_processor", PPTX_PROCESSOR.to_vec()); + + // Dev-only freshness checks. Warning output is opt-in via + // II_AGENT_WASM_FRESHNESS_WARN to avoid noisy logs during normal + // desktop development. + #[cfg(debug_assertions)] + { + let base = concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/cowork/desktop_runtime/wasm" + ); + freshness::check_freshness( + "pdf_processor", + &format!("{base}/modules/pdf_processor.wasm"), + &format!("{base}/guest/pdf_processor/src"), + ); + freshness::check_freshness( + "docx_processor", + &format!("{base}/modules/docx_processor.wasm"), + &format!("{base}/guest/docx_processor/src"), + ); + freshness::check_freshness( + "xlsx_processor", + &format!("{base}/modules/xlsx_processor.wasm"), + &format!("{base}/guest/xlsx_processor/src"), + ); + freshness::check_freshness( + "pptx_processor", + &format!("{base}/modules/pptx_processor.wasm"), + &format!("{base}/guest/pptx_processor/src"), + ); + } + } + + pub const KIND: RuntimeKind = RuntimeKind::Wasm; + + /// Borrow the module registry so callers can register application + /// assets at startup. + pub fn registry(&self) -> &ModuleRegistry { + &self.registry + } + + /// Configure the root directory used to create per-call scratch + /// workspaces. Typically `{app_data}/cowork/wasm-scratch`. + pub fn set_scratch_root(&self, root: PathBuf) { + *lock_or_recover(&self.scratch_root) = Some(root); + } + + /// Execute a single WASM call and return a structured result. + pub fn run(&self, request: WasmRunRequest) -> Result { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.run_inner(request))).map_err( + |payload| { + WasmRunError::Execution(format!( + "WASM runtime panicked: {}", + panic_payload_message(payload.as_ref()) + )) + }, + )? + } + + fn run_inner(&self, request: WasmRunRequest) -> Result { + let limits = request.limits.unwrap_or_else(RuntimeLimits::defaults); + + // --- Prepare scratch workspace --- + let scratch_dir = self.build_scratch_dir(request.session_id.as_deref())?; + let inputs_dir = scratch_dir.join("inputs"); + let outputs_dir = scratch_dir.join("outputs"); + fs::create_dir_all(&inputs_dir)?; + fs::create_dir_all(&outputs_dir)?; + + if let Some(input_json) = request.input_json.as_ref() { + let serialised = serde_json::to_vec_pretty(input_json) + .map_err(|error| WasmRunError::Io(error.to_string()))?; + fs::write(scratch_dir.join("input.json"), serialised)?; + } + + for (source_path, logical_name) in &request.input_files { + // Resolve symlinks before copying so a symlink pointing + // outside the desktop scope cannot smuggle host files into + // the sandbox. + let canonical = fs::canonicalize(source_path).map_err(|error| { + WasmRunError::Io(format!( + "failed to resolve input file {}: {}", + source_path.display(), + error + )) + })?; + let destination = inputs_dir.join(logical_name); + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(&canonical, &destination).map_err(|error| { + WasmRunError::Io(format!( + "failed to copy input file {} to scratch: {}", + canonical.display(), + error + )) + })?; + } + + // --- Load module --- + let module = self + .registry + .load_module(&self.engine, &request.module_name)?; + + // --- Build WASI context --- + let stdout_pipe = wasmtime_wasi::pipe::MemoryOutputPipe::new(256 * 1024); + let stderr_pipe = wasmtime_wasi::pipe::MemoryOutputPipe::new(256 * 1024); + + let wasi = WasiCtxBuilder::new() + .stdout(stdout_pipe.clone()) + .stderr(stderr_pipe.clone()) + .preopened_dir( + &scratch_dir, + "/workspace", + DirPerms::all(), + FilePerms::all(), + ) + .map_err(|error| WasmRunError::Io(error.to_string()))? + .build_p1(); + + let store_limits = StoreLimitsBuilder::new() + .memory_size(limits.max_memory_bytes) + .build(); + + let host_state = HostState { + wasi, + limits: store_limits, + memory_tripped: false, + }; + + let mut store = Store::new(&self.engine, host_state); + store + .set_fuel(limits.max_fuel) + .map_err(|error| WasmRunError::Execution(error.to_string()))?; + store.limiter(|state| &mut state.limits); + store.set_epoch_deadline(1); + + // --- Instantiate + link WASI --- + let mut linker: Linker = Linker::new(&self.engine); + wasi_preview1::add_to_linker_sync(&mut linker, |state| &mut state.wasi) + .map_err(|error| WasmRunError::Execution(error.to_string()))?; + + let start_time = Instant::now(); + let epoch_handle = EpochDeadlineHandle::new(&self.engine, limits.wall_timeout); + + let run_outcome: Result<(), wasmtime::Error> = (|| { + let instance = linker.instantiate(&mut store, &module)?; + match &request.entrypoint { + WasmEntrypoint::Start => { + if let Some(start) = instance.get_func(&mut store, "_start") { + let typed = start.typed::<(), ()>(&store)?; + typed.call(&mut store, ())?; + } else { + return Err(wasmtime::Error::msg( + "module has no '_start' export; set a NamedVoid entrypoint", + )); + } + } + WasmEntrypoint::NamedVoid(name) => { + let func = instance + .get_func(&mut store, name.as_str()) + .ok_or_else(|| { + wasmtime::Error::msg(format!( + "module has no exported function '{}'", + name + )) + })?; + let typed = func.typed::<(), ()>(&store)?; + typed.call(&mut store, ())?; + } + } + Ok(()) + })(); + + drop(epoch_handle); + + let duration = start_time.elapsed(); + + if let Err(error) = run_outcome { + let message = format!("{error:?}"); + // Classify the error into a friendlier variant when possible. + if message.contains("epoch deadline") { + return Err(WasmRunError::Timeout(limits.wall_timeout)); + } + if message.contains("all fuel consumed") { + return Err(WasmRunError::OutOfFuel); + } + if store.data().memory_tripped { + return Err(WasmRunError::MemoryLimit(limits.max_memory_bytes)); + } + // Graceful WASI exit(0) also surfaces as an error here. + if message.contains("Exited with i32 exit status 0") { + // fall through to success path below + } else if !message.contains("Exited with i32 exit status 0") { + return Err(WasmRunError::Execution(message)); + } + } + + // --- Collect outputs --- + let stdout_bytes = stdout_pipe.contents(); + let stderr_bytes = stderr_pipe.contents(); + let stdout_text = String::from_utf8_lossy(&stdout_bytes).into_owned(); + let stderr_text = String::from_utf8_lossy(&stderr_bytes).into_owned(); + + let output_json = { + let output_path = scratch_dir.join("output.json"); + if output_path.exists() { + let bytes = fs::read(&output_path)?; + match serde_json::from_slice::(&bytes) { + Ok(value) => Some(value), + Err(_) => None, + } + } else { + None + } + }; + + let output_files = collect_output_artifacts(&outputs_dir)?; + + let result = WasmRunResult { + module: request.module_name.clone(), + stdout: stdout_text, + stderr: stderr_text, + duration_ms: duration.as_millis(), + output_json, + output_files, + scratch_dir: if request.keep_workspace { + Some(scratch_dir.clone()) + } else { + None + }, + }; + + if !request.keep_workspace { + let _ = fs::remove_dir_all(&scratch_dir); + } + + Ok(result) + } + + fn build_scratch_dir(&self, session_id: Option<&str>) -> Result { + let base = match lock_or_recover(&self.scratch_root).clone() { + Some(root) => root, + None => std::env::temp_dir().join("ii-cowork-wasm"), + }; + let scoped = match session_id { + Some(session_id) => base.join(sanitize_session_segment(session_id)), + None => base, + }; + fs::create_dir_all(&scoped)?; + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + // Random suffix prevents collision when two calls happen in + // the same nanosecond (unlikely but possible under load). + let rand_suffix: u32 = (nanos as u32).wrapping_mul(2654435761); + let subdir = scoped.join(format!("call-{nanos}-{rand_suffix:08x}")); + fs::create_dir_all(&subdir)?; + Ok(subdir) + } + + /// Remove every scratch directory belonging to a given session. + /// + /// Called by the cowork session gateway when a session is closed or + /// deleted. Silently succeeds if the session has no scratch state. + pub fn sweep_session(&self, session_id: &str) -> Result<(), WasmRunError> { + let Some(base) = lock_or_recover(&self.scratch_root).clone() else { + return Ok(()); + }; + let scoped = base.join(sanitize_session_segment(session_id)); + if scoped.exists() { + fs::remove_dir_all(&scoped)?; + } + Ok(()) + } + + /// Remove the entire scratch root. Called at desktop app startup so + /// leftover artifacts from previous runs do not accumulate. + pub fn sweep_all(&self) -> Result<(), WasmRunError> { + let Some(base) = lock_or_recover(&self.scratch_root).clone() else { + return Ok(()); + }; + if base.exists() { + fs::remove_dir_all(&base)?; + } + fs::create_dir_all(&base)?; + Ok(()) + } +} + +/// Sanitise a cowork session identifier so it is safe to use as a +/// directory name. Anything that is not alphanumeric, `-`, `_`, or `.` is +/// replaced with `_`. +fn sanitize_session_segment(session_id: &str) -> String { + session_id + .chars() + .map(|c| match c { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => c, + _ => '_', + }) + .collect() +} + +/// Shared host state available to wasmtime callbacks. +struct HostState { + wasi: WasiP1Ctx, + limits: StoreLimits, + memory_tripped: bool, +} + +fn collect_output_artifacts(outputs_dir: &Path) -> Result, WasmRunError> { + if !outputs_dir.exists() { + return Ok(Vec::new()); + } + let mut artifacts = Vec::new(); + for entry in fs::read_dir(outputs_dir)? { + let entry = entry?; + let metadata = entry.metadata()?; + if metadata.is_file() { + artifacts.push(WasmOutputArtifact { + name: entry.file_name().to_string_lossy().into_owned(), + bytes: metadata.len() as usize, + }); + } + } + artifacts.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(artifacts) +} + +/// Background thread that bumps the wasmtime engine epoch after the wall +/// deadline so the guest is interrupted even when fuel is not being +/// consumed. +struct EpochDeadlineHandle { + shutdown: Arc>, + thread: Option>, +} + +impl EpochDeadlineHandle { + fn new(engine: &Engine, deadline: Duration) -> Self { + let shutdown = Arc::new(Mutex::new(false)); + let shutdown_clone = Arc::clone(&shutdown); + let engine = engine.clone(); + let thread = thread::spawn(move || { + let start = Instant::now(); + while start.elapsed() < deadline { + if *lock_or_recover(&shutdown_clone) { + return; + } + thread::sleep(Duration::from_millis(25)); + } + engine.increment_epoch(); + }); + Self { + shutdown, + thread: Some(thread), + } + } +} + +impl Drop for EpochDeadlineHandle { + fn drop(&mut self) { + *lock_or_recover(&self.shutdown) = true; + if let Some(handle) = self.thread.take() { + let _ = handle.join(); + } + } +} + +fn lock_or_recover(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn panic_payload_message(payload: &(dyn Any + Send)) -> String { + if let Some(message) = payload.downcast_ref::<&str>() { + (*message).to_string() + } else if let Some(message) = payload.downcast_ref::() { + message.clone() + } else { + "non-string panic payload".to_string() + } +} + +// Global runtime access is provided by [`lifecycle::acquire_runtime`]. +// It replaces an earlier `global()` helper that held the engine alive +// forever. The lifecycle module manages lazy creation, in-flight +// counting, and idle drop. + +#[cfg(test)] +mod tests { + use super::*; + + const HELLO_WAT: &str = r#" + (module + (import "wasi_snapshot_preview1" "fd_write" + (func $fd_write (param i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 8) "hello wasm\n") + (func $main (export "_start") + (i32.store (i32.const 0) (i32.const 8)) + (i32.store (i32.const 4) (i32.const 11)) + (call $fd_write (i32.const 1) (i32.const 0) (i32.const 1) (i32.const 20)) + drop) + ) + "#; + + #[test] + fn runtime_runs_embedded_wat_module() { + let runtime = WasmRuntime::new().expect("runtime construction"); + runtime.registry().register_wat("hello", HELLO_WAT); + assert!(runtime.registry().has("hello")); + + let request = WasmRunRequest::new("hello"); + let result = runtime.run(request).expect("hello module runs"); + assert_eq!(result.module, "hello"); + assert!( + result.stdout.contains("hello wasm"), + "expected hello wasm in stdout, got {:?}", + result.stdout + ); + assert!(result.scratch_dir.is_none()); + } + + #[test] + fn runtime_reports_unknown_module() { + let runtime = WasmRuntime::new().expect("runtime construction"); + let err = runtime + .run(WasmRunRequest::new("does-not-exist")) + .unwrap_err(); + match err { + WasmRunError::UnknownModule(name) => assert_eq!(name, "does-not-exist"), + other => panic!("expected UnknownModule, got {other:?}"), + } + } + + #[test] + fn runtime_reports_module_list() { + let runtime = WasmRuntime::new().expect("runtime construction"); + runtime.registry().register_wat("test_b", HELLO_WAT); + runtime.registry().register_wat("test_a", HELLO_WAT); + let names = runtime.registry().names(); + assert!(names.contains(&"test_a".to_string())); + assert!(names.contains(&"test_b".to_string())); + // Built-in modules like pdf_processor are registered by + // `WasmRuntime::new()` via `include_bytes!`, so the list + // contains them too. We only assert the test-registered names + // made it in, not the exact shape of the list. + } + + #[test] + fn session_id_scopes_scratch_dir() { + let runtime = WasmRuntime::new().expect("runtime construction"); + runtime.registry().register_wat("hello", HELLO_WAT); + + let root = std::env::temp_dir().join(format!( + "ii-cowork-wasm-lifecycle-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or_default() + )); + runtime.set_scratch_root(root.clone()); + + let request = WasmRunRequest::new("hello") + .with_session_id("session-xyz") + .with_input_json(serde_json::json!({"hello": "world"})); + let result = runtime + .run(WasmRunRequest { + keep_workspace: true, + ..request + }) + .expect("runs"); + + let scratch = result.scratch_dir.expect("kept scratch"); + assert!( + scratch.starts_with(root.join("session-xyz")), + "scratch dir {:?} should live under the session-scoped root", + scratch + ); + + runtime + .sweep_session("session-xyz") + .expect("session sweep succeeds"); + assert!(!root.join("session-xyz").exists()); + + runtime.sweep_all().expect("sweep all succeeds"); + assert!(root.exists(), "sweep_all recreates the empty root"); + + let _ = std::fs::remove_dir_all(&root); + } + + /// End-to-end: boot the real runtime (which auto-registers + /// pdf_processor.wasm via `include_bytes!`), prepare a real PDF input, + /// run the module, and assert the guest produced a structured + /// `output.json` with extracted text. + /// + /// This is the canonical proof that the full sandbox pipeline works: + /// Rust→wasm32-wasip1 module loaded → WASI preopen → guest reads + /// `/workspace/inputs/input.pdf` → lopdf parses → writes + /// `/workspace/output.json` → host reads and returns structured + /// result. + #[test] + fn pdf_processor_extract_text_end_to_end() { + const HELLO_PDF: &[u8] = include_bytes!("../../../../tests/fixtures/hello.pdf"); + + let runtime = WasmRuntime::new().expect("runtime construction"); + assert!( + runtime.registry().has("pdf_processor"), + "pdf_processor module should be auto-registered via include_bytes!" + ); + + // Stage the input PDF on the host side so we can pass it as an + // input file to the runtime (the runtime copies it into the + // sandbox's /workspace/inputs/ dir). + let stage_dir = std::env::temp_dir().join(format!( + "pdf-extract-stage-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or_default() + )); + std::fs::create_dir_all(&stage_dir).unwrap(); + let pdf_path = stage_dir.join("hello.pdf"); + std::fs::write(&pdf_path, HELLO_PDF).unwrap(); + + let request = WasmRunRequest::new("pdf_processor") + .with_session_id("pdf-e2e-test") + .with_input_json(serde_json::json!({ "op": "extract_text" })); + let request = WasmRunRequest { + input_files: vec![(pdf_path.clone(), "input.pdf".to_string())], + ..request + }; + + let result = runtime.run(request).expect("pdf_processor runs"); + + assert_eq!(result.module, "pdf_processor"); + assert!( + result.stdout.contains("pdf_processor: op=extract_text ok"), + "expected stdout success line, got: {:?}", + result.stdout + ); + + let output = result + .output_json + .as_ref() + .expect("pdf_processor should write /workspace/output.json"); + let pages = output + .get("pages") + .and_then(|value| value.as_array()) + .expect("output.json.pages must be an array"); + assert!(!pages.is_empty(), "pages must not be empty"); + let joined: String = pages + .iter() + .filter_map(|page| page.as_str()) + .collect::>() + .join(" "); + assert!( + joined.contains("Hello Desktop Skill"), + "extracted text should contain the fixture phrase, got: {joined:?}" + ); + + std::fs::remove_dir_all(&stage_dir).ok(); + } + + /// End-to-end with `page_range`: we hand the guest a 1-page fixture + /// PDF but ask for `[1, 1]` explicitly, and verify it honours the + /// range plus reports `page_offset`/`total_pages`. This is the + /// smoking gun that our rebuilt `pdf_processor.wasm` picks up the + /// new input contract — the chunking dispatcher relies on this + /// contract for correctness. + #[test] + fn pdf_processor_respects_page_range() { + const HELLO_PDF: &[u8] = include_bytes!("../../../../tests/fixtures/hello.pdf"); + + let runtime = WasmRuntime::new().expect("runtime construction"); + let stage_dir = std::env::temp_dir().join(format!( + "pdf-page-range-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or_default() + )); + std::fs::create_dir_all(&stage_dir).unwrap(); + let pdf_path = stage_dir.join("hello.pdf"); + std::fs::write(&pdf_path, HELLO_PDF).unwrap(); + + // In-range: ask for pages 1-1, expect 1 page with the fixture text. + let in_range = WasmRunRequest { + input_files: vec![(pdf_path.clone(), "input.pdf".to_string())], + ..WasmRunRequest::new("pdf_processor") + .with_session_id("pdf-range-in") + .with_input_json(serde_json::json!({ + "op": "extract_text", + "page_range": [1, 1] + })) + }; + let result = runtime.run(in_range).expect("pdf_processor runs"); + let output = result.output_json.expect("output.json"); + let pages = output + .get("pages") + .and_then(|v| v.as_array()) + .expect("pages array"); + assert_eq!(pages.len(), 1, "expected exactly 1 page in slice"); + assert_eq!(output.get("page_offset").and_then(|v| v.as_u64()), Some(1)); + assert_eq!(output.get("total_pages").and_then(|v| v.as_u64()), Some(1)); + + // Out-of-range: ask for pages 10-20 on a 1-page doc, expect + // empty pages array and no error (guest clamps). + let out_of_range = WasmRunRequest { + input_files: vec![(pdf_path.clone(), "input.pdf".to_string())], + ..WasmRunRequest::new("pdf_processor") + .with_session_id("pdf-range-out") + .with_input_json(serde_json::json!({ + "op": "extract_text", + "page_range": [10, 20] + })) + }; + let result2 = runtime + .run(out_of_range) + .expect("pdf_processor runs out-of-range"); + let output2 = result2.output_json.expect("output.json"); + let pages2 = output2 + .get("pages") + .and_then(|v| v.as_array()) + .expect("pages array"); + assert!( + pages2.is_empty(), + "out-of-range should produce empty pages, got {} pages", + pages2.len() + ); + + std::fs::remove_dir_all(&stage_dir).ok(); + } + + #[test] + fn docx_processor_extract_text_end_to_end() { + const HELLO_DOCX: &[u8] = include_bytes!("../../../../tests/fixtures/hello.docx"); + let runtime = WasmRuntime::new().expect("runtime"); + let stage = stage_file(HELLO_DOCX, "hello.docx"); + let request = WasmRunRequest { + input_files: vec![(stage.join("hello.docx"), "input.docx".to_string())], + ..WasmRunRequest::new("docx_processor") + .with_session_id("docx-e2e") + .with_input_json(serde_json::json!({"op": "extract_text"})) + }; + let result = runtime.run(request).expect("docx_processor runs"); + let output = result.output_json.expect("output.json"); + let paragraphs = output + .get("paragraphs") + .and_then(|v| v.as_array()) + .expect("paragraphs"); + assert!(!paragraphs.is_empty()); + let joined: String = paragraphs + .iter() + .filter_map(|p| p.as_str()) + .collect::>() + .join(" "); + assert!( + joined.contains("Hello Desktop Skill Docx"), + "got: {joined:?}" + ); + std::fs::remove_dir_all(&stage).ok(); + } + + #[test] + fn xlsx_processor_list_sheets_end_to_end() { + const HELLO_XLSX: &[u8] = include_bytes!("../../../../tests/fixtures/hello.xlsx"); + let runtime = WasmRuntime::new().expect("runtime"); + let stage = stage_file(HELLO_XLSX, "hello.xlsx"); + let request = WasmRunRequest { + input_files: vec![(stage.join("hello.xlsx"), "input.xlsx".to_string())], + ..WasmRunRequest::new("xlsx_processor") + .with_session_id("xlsx-e2e") + .with_input_json(serde_json::json!({"op": "list_sheets"})) + }; + let result = runtime.run(request).expect("xlsx_processor runs"); + let output = result.output_json.expect("output.json"); + let sheets = output + .get("sheets") + .and_then(|v| v.as_array()) + .expect("sheets"); + assert!(!sheets.is_empty()); + let first_name = sheets[0].get("name").and_then(|v| v.as_str()).unwrap_or(""); + assert_eq!(first_name, "TestSheet"); + std::fs::remove_dir_all(&stage).ok(); + } + + #[test] + fn xlsx_processor_read_sheet_end_to_end() { + const HELLO_XLSX: &[u8] = include_bytes!("../../../../tests/fixtures/hello.xlsx"); + let runtime = WasmRuntime::new().expect("runtime"); + let stage = stage_file(HELLO_XLSX, "hello.xlsx"); + let request = WasmRunRequest { + input_files: vec![(stage.join("hello.xlsx"), "input.xlsx".to_string())], + ..WasmRunRequest::new("xlsx_processor") + .with_session_id("xlsx-read-e2e") + .with_input_json(serde_json::json!({"op": "read_sheet", "sheet": "TestSheet"})) + }; + let result = runtime.run(request).expect("xlsx_processor runs"); + let output = result.output_json.expect("output.json"); + let rows = output.get("rows").and_then(|v| v.as_array()).expect("rows"); + assert_eq!(rows.len(), 2); + // Row 1: ["Hello", "Desktop"] + let row1 = rows[0].as_array().expect("row1"); + assert_eq!(row1[0].as_str(), Some("Hello")); + assert_eq!(row1[1].as_str(), Some("Desktop")); + std::fs::remove_dir_all(&stage).ok(); + } + + #[test] + fn pptx_processor_extract_text_end_to_end() { + const HELLO_PPTX: &[u8] = include_bytes!("../../../../tests/fixtures/hello.pptx"); + let runtime = WasmRuntime::new().expect("runtime"); + let stage = stage_file(HELLO_PPTX, "hello.pptx"); + let request = WasmRunRequest { + input_files: vec![(stage.join("hello.pptx"), "input.pptx".to_string())], + ..WasmRunRequest::new("pptx_processor") + .with_session_id("pptx-e2e") + .with_input_json(serde_json::json!({"op": "extract_text"})) + }; + let result = runtime.run(request).expect("pptx_processor runs"); + let output = result.output_json.expect("output.json"); + let slides = output + .get("slides") + .and_then(|v| v.as_array()) + .expect("slides"); + assert!(!slides.is_empty()); + let text = slides[0].get("text").and_then(|v| v.as_str()).unwrap_or(""); + assert!(text.contains("Hello Desktop Skill Pptx"), "got: {text:?}"); + std::fs::remove_dir_all(&stage).ok(); + } + + /// Helper: write fixture bytes to a temp dir so tests can pass them + /// as input_files to the runtime. + fn stage_file(bytes: &[u8], filename: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "e2e-{}-{}", + filename, + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or_default() + )); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(filename), bytes).unwrap(); + dir + } + + /// Verifies that the skill registry resolves the pdf skill to the + /// pdf_processor module name, and that the runtime registry agrees. + /// This is the "match" step desktop_skill_run performs before + /// calling into the runtime. + #[test] + fn pdf_skill_resolves_to_registered_module() { + use crate::cowork::desktop_skills::find_builtin_skill; + + let skill = find_builtin_skill("pdf").expect("pdf skill registered"); + let module_name = skill.wasm_module().expect("pdf skill declares wasm_module"); + assert_eq!(module_name, "pdf_processor"); + + let runtime = WasmRuntime::new().expect("runtime construction"); + assert!( + runtime.registry().has(module_name), + "pdf skill's wasm_module should be registered in the runtime" + ); + } + + #[test] + fn sanitise_session_segment_is_filesystem_safe() { + // `.`, `-`, `_`, and alphanumerics pass through unchanged. + assert_eq!(sanitize_session_segment("abc_123-x.y"), "abc_123-x.y"); + // Path separators and backslashes collapse to `_`. Parent-dir + // dots are preserved as dots (they are harmless inside a single + // segment). + assert_eq!( + sanitize_session_segment("../../etc/passwd"), + ".._.._etc_passwd" + ); + assert_eq!(sanitize_session_segment("a/b\\c"), "a_b_c"); + } +} diff --git a/frontend/src-tauri/src/cowork/desktop_runtime/wasm/modules/README.md b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/modules/README.md new file mode 100644 index 000000000..bacb983cb --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/modules/README.md @@ -0,0 +1,64 @@ +# Desktop WASM guest modules + +This directory holds the compiled `.wasm` artifacts that are bundled into +the desktop binary at compile time via `include_bytes!` in +[`../mod.rs`](../mod.rs). + +Each file here is produced by building a crate under [`../guest/`](../guest/) +for the `wasm32-wasip1` target. The bytes are then copied into this +directory and registered with the runtime's `ModuleRegistry` when +`WasmRuntime::new()` runs. + +## Rebuilding a module + +After editing any guest crate (for example, `../guest/pdf_processor/`): + +```bash +cd src/cowork/desktop_runtime/wasm/guest/pdf_processor +rustup target add wasm32-wasip1 # only needed once +cargo build --target wasm32-wasip1 --release + +# Copy the compiled artifact into this modules/ directory so +# include_bytes! picks up the new bytes on the next cargo check. +cp target/wasm32-wasip1/release/pdf_processor.wasm ../../modules/pdf_processor.wasm +``` + +Then run `cargo check` on the top-level `src-tauri` crate — it will +recompile the host binary with the new guest bytes embedded. + +## Freshness warnings + +The desktop runtime can detect when a guest module's source is newer than +the compiled `.wasm` artifact, but that warning is disabled by default to +avoid noisy local logs. + +If you want to see the warning while debugging, start the desktop app with +`II_AGENT_WASM_FRESHNESS_WARN=1`. + +## Why separate `guest/` and `modules/`? + +- `guest/` holds *source code* for the WASM programs. Each guest is its + own cargo crate with its own dependency tree and its own target + triple (`wasm32-wasip1`). These crates are **not** part of the main + `src-tauri` workspace — cargo does not walk into them during normal + host builds. +- `modules/` holds the *compiled bytes* that the host embeds at + compile time. These bytes are the only thing the running desktop app + sees; the guest source code is only needed when you want to change + what a skill does inside the sandbox. + +Keeping the source and the artifact separate means the host crate +(`src-tauri`) depends on exactly one file per module — the +`.wasm` artifact — instead of triggering a `wasm32-wasip1` toolchain +build on every `cargo check`. + +## Current modules + +| File | Built from | Used by skill | +|------|------------|----------------| +| `pdf_processor.wasm` | `../guest/pdf_processor/` | `pdf` (extract_text, metadata) | + +`docx`, `xlsx`, and `pptx` are registered as skills but do not yet have +corresponding WASM modules. Calls targeting those modules will return a +clear "module not registered" error until their guest crates are added +here. diff --git a/frontend/src-tauri/src/cowork/desktop_runtime/wasm/modules/docx_processor.wasm b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/modules/docx_processor.wasm new file mode 100644 index 000000000..eb4f87664 Binary files /dev/null and b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/modules/docx_processor.wasm differ diff --git a/frontend/src-tauri/src/cowork/desktop_runtime/wasm/modules/pdf_processor.wasm b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/modules/pdf_processor.wasm new file mode 100644 index 000000000..b44af3412 Binary files /dev/null and b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/modules/pdf_processor.wasm differ diff --git a/frontend/src-tauri/src/cowork/desktop_runtime/wasm/modules/pptx_processor.wasm b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/modules/pptx_processor.wasm new file mode 100644 index 000000000..6e84b930c Binary files /dev/null and b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/modules/pptx_processor.wasm differ diff --git a/frontend/src-tauri/src/cowork/desktop_runtime/wasm/modules/xlsx_processor.wasm b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/modules/xlsx_processor.wasm new file mode 100644 index 000000000..4714d7bd0 Binary files /dev/null and b/frontend/src-tauri/src/cowork/desktop_runtime/wasm/modules/xlsx_processor.wasm differ diff --git a/frontend/src-tauri/src/cowork/desktop_skills/desktop_skill_run.rs b/frontend/src-tauri/src/cowork/desktop_skills/desktop_skill_run.rs new file mode 100644 index 000000000..cb7122137 --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_skills/desktop_skill_run.rs @@ -0,0 +1,146 @@ +//! `desktop_skill_run` — skill body loader tool. +//! +//! This tool exposes the full markdown body of a desktop skill to the +//! backend agent. The LLM calls it with a skill name; the tool returns +//! the raw `SKILL.md` body that lives bundled inside the desktop binary. +//! The body describes the skill's supported operations, the exact +//! `wasm_run` invocations the agent should use, and any fallback +//! guidance when the backing WASM module is not registered. +//! +//! ## Execution model +//! +//! `desktop_skill_run` **never touches the WASM runtime**. It is a pure +//! text-load tool: input is a skill name, output is the body markdown. +//! After reading the body, the agent decides whether to call `wasm_run` +//! (the executor tool in [`crate::cowork::desktop_tools::wasm_run`]) +//! with the shape the body recommends, or to fall back to host tools, +//! or to tell the user the task is not supported. +//! +//! This separation keeps two concerns independent: +//! +//! * **Guidance** (who decides what to do) — owned by skills, surfaced by +//! this tool. +//! * **Execution** (who runs the isolated code) — owned by `wasm_run` + +//! the desktop WASM runtime. +//! +//! The backend agent is expected to follow a two-step pattern for any +//! skill-driven task: +//! +//! 1. Call `desktop_skill_run(skill_name="pdf")` to load the pdf skill's +//! body into context. +//! 2. Follow the body's instructions — usually that means calling +//! `wasm_run(module=..., input_json=..., input_files=...)` with the +//! exact shape documented there. + +use super::find_builtin_skill; +use crate::cowork::agent_presets::shared::DesktopToolCapability; +use crate::cowork::desktop_tools::{DesktopTool, DesktopToolContext}; +use serde_json::{json, Value}; + +pub const TOOL_DESKTOP_SKILL_RUN: &str = "desktop_skill_run"; + +pub fn desktop_tool() -> DesktopTool { + DesktopTool::new( + DesktopToolCapability { + name: TOOL_DESKTOP_SKILL_RUN.to_string(), + aliases: Vec::new(), + display_name: "Load a desktop skill body".to_string(), + description: "Load the full markdown body of a desktop skill by name. Use this BEFORE attempting to process a complex document format (pdf, docx, xlsx, pptx) so you can read the skill's decision tree and the exact wasm_run invocation it recommends. This tool does NOT execute anything — it only returns the skill's instructions. After reading the body, call wasm_run yourself with the shape the body documents. If the body reports that the backing WebAssembly module is not yet registered, fall back to filename-level operations and tell the user what is not supported.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "skill_name": { + "type": "string", + "description": "Name of the desktop skill to load. Known names: 'pdf', 'docx', 'xlsx', 'pptx'." + } + }, + "required": ["skill_name"] + }), + }, + execute, + false, + ) +} + +fn execute(_ctx: &mut DesktopToolContext<'_>, tool_input: &Value) -> Result { + let skill_name = tool_input + .get("skill_name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "desktop_skill_run requires a 'skill_name' string".to_string())?; + + let skill = find_builtin_skill(skill_name).ok_or_else(|| { + format!( + "desktop_skill_run: unknown skill '{skill_name}'. Known skills: pdf, docx, xlsx, pptx." + ) + })?; + + let runtime_label = skill.runtime().as_str(); + let module_label = skill + .wasm_module() + .map(|name| format!("\nwasm_module: {name}")) + .unwrap_or_default(); + + Ok(format!( + "# Skill: {name}\n\ +description: {description}\n\ +runtime: {runtime}{module_label}\n\ +\n\ +Follow the instructions below. When the body tells you to call wasm_run, use the exact shape documented. Do not invent parameters the body does not mention.\n\ +\n\ +---\n\ +\n\ +{body}", + name = skill.name(), + description = skill.description(), + runtime = runtime_label, + module_label = module_label, + body = skill.body(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn desktop_tool_descriptor_is_wellformed() { + let tool = desktop_tool(); + assert_eq!(tool.name(), TOOL_DESKTOP_SKILL_RUN); + assert!(!tool.display_name().is_empty()); + let cap = tool.clone().into_capability(); + let schema = cap.input_schema.as_object().expect("schema is object"); + let required = schema + .get("required") + .and_then(Value::as_array) + .expect("required list"); + assert_eq!(required.len(), 1); + assert_eq!(required[0].as_str(), Some("skill_name")); + } + + // The tool's execute() signature needs a DesktopToolContext which + // carries an AppHandle. Rather than mocking the full Tauri surface + // here, we test the core lookup logic directly against the skill + // registry — that exercises the only path execute() actually runs + // for a valid skill name. + #[test] + fn loads_pdf_skill_body() { + let skill = find_builtin_skill("pdf").expect("pdf skill registered"); + assert_eq!(skill.name(), "pdf"); + assert!(!skill.body().is_empty(), "pdf body must not be empty"); + let lower = skill.body().to_ascii_lowercase(); + assert!( + lower.contains("wasm_run") || lower.contains("pdf_processor"), + "pdf body must reference wasm_run or pdf_processor so the agent knows how to invoke it" + ); + } + + #[test] + fn unknown_skill_is_reported_clearly() { + // This mirrors the error branch in execute() without needing a + // DesktopToolContext: if find_builtin_skill returns None, we + // should produce a helpful error listing known skill names. + assert!(find_builtin_skill("no-such-skill").is_none()); + } +} diff --git a/frontend/src-tauri/src/cowork/desktop_skills/docx.skill.md b/frontend/src-tauri/src/cowork/desktop_skills/docx.skill.md new file mode 100644 index 000000000..0db11ec86 --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_skills/docx.skill.md @@ -0,0 +1,83 @@ +--- +name: docx +description: Desktop Word document handling via an isolated WebAssembly module. Supports text extraction and paragraph counting. +version: 0.1.0 +runtime: wasm +wasm_module: docx_processor +--- + +You are about to work with a Word document inside the selected desktop +folder. Read this body, then pick the operation that matches the task. + +# When to use this skill + +Use when the task requires reading the body text of a `.docx` file. +Do NOT use for plain text/md/txt files (use `Read` directly), or for +tasks that only care about filenames (use `glob`/`list_dir`). + +# Host first, sandbox second + +1. Use `glob` / `list_dir` to find the `.docx` files. +2. If the task only needs filenames or folder structure, answer from + host tools and stop. +3. If you need the document content, call `wasm_run` below. +4. Never try to `Read` or `Edit` a `.docx` file directly — it is a + zipped OOXML container and any byte-level operation will corrupt it + or produce gibberish. + +# Operations + +## extract_text + +Extract all paragraph text in reading order. + +``` +wasm_run({ + "module": "docx_processor", + "input_json": { "op": "extract_text" }, + "input_files": [{ "path": "", "name": "input.docx" }] +}) +``` + +Response `output.json`: +``` +{ "paragraphs": ["...", "..."], "paragraph_offset": 1, "total_paragraphs": N } +``` + +### Optional paragraph_range + +``` +wasm_run({ + "module": "docx_processor", + "input_json": { "op": "extract_text", "paragraph_range": [1, 100] }, + "input_files": [{ "path": "", "name": "input.docx" }] +}) +``` + +Out-of-range requests are clamped. The host automatically chunks large +files and merges results — you always see one combined array. + +## count_paragraphs + +``` +wasm_run({ + "module": "docx_processor", + "input_json": { "op": "count_paragraphs" }, + "input_files": [{ "path": "", "name": "input.docx" }] +}) +``` + +Response: `{ "total_paragraphs": N }` + +# Error handling + +- If `wasm_run` reports a memory limit error, the docx is too large. + Report this to the user. +- If `wasm_run` reports `docx parse failed`, the file is corrupt. +- Never try to "fix" a .docx file with `Edit` — you will corrupt it. + +# What to return to the user + +- Files inspected and operations run. +- Relevant paragraph text or summaries. +- Any errors encountered with file paths. diff --git a/frontend/src-tauri/src/cowork/desktop_skills/mod.rs b/frontend/src-tauri/src/cowork/desktop_skills/mod.rs new file mode 100644 index 000000000..1a25d2fdb --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_skills/mod.rs @@ -0,0 +1,381 @@ +//! Desktop skills: first-class local capabilities the agent can reason about. +//! +//! A *skill* is a packaged piece of guidance that tells the agent how to +//! approach a class of tasks (for example, "process a PDF file"). Unlike a +//! tool, a skill is not invoked directly by the model — it surfaces to the +//! agent as a descriptor (name + short description) that the backend +//! agent reads before planning, and the full body is available for the +//! agent to pull in when it decides the skill is relevant. +//! +//! ## Package layout +//! +//! The desktop app owns skill storage. Built-in skills live as Markdown +//! files in `assets/desktop_skills/` and are bundled into the binary via +//! [`include_str!`]. Each skill file has YAML-like frontmatter and a +//! Markdown body: +//! +//! ```markdown +//! --- +//! name: pdf +//! description: Isolated PDF processing on the desktop runtime. +//! version: 0.1.0 +//! triggers: pdf document processing, pdf text extraction +//! runtime: wasm +//! wasm_module: pdf_processor +//! tools: Read, wasm_run +//! --- +//! +//! # PDF Skill +//! +//! ... +//! ``` +//! +//! All frontmatter fields except `name` and `description` are optional. +//! `runtime` picks between `host` (the skill is host-tool driven) and +//! `wasm` (the skill expects an isolated runtime call through +//! `wasm_run`). +//! +//! ## Registry +//! +//! [`common_desktop_skills`] returns the canonical set of built-in +//! skills. Callers that want mode-specific additions merge their own +//! lists, just like the desktop tool layer does. Skill discovery and +//! metadata parsing happen at this layer — downstream code should never +//! parse the raw Markdown directly. +//! +//! ## Tool surface +//! +//! The desktop skill module also owns the `desktop_skill_run` tool +//! (see [`desktop_skill_run`]). That tool lives here — not in the +//! generic `desktop_tools/` folder — because its only job is to load a +//! skill body into the agent's context. Execution of the isolated +//! runtime stays in [`crate::cowork::desktop_tools::wasm_run`]. + +#![allow(dead_code)] + +pub mod desktop_skill_run; + +use crate::cowork::agent_presets::shared::DesktopSkillCapability; + +/// Target runtime for a desktop skill. Guides the agent on when to reach +/// for isolated execution versus normal host tools. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SkillRuntime { + /// The skill expects to drive the host tools (Read/Write/Edit/Bash). + Host, + /// The skill expects to call into the WASM runtime via `wasm_run`. + Wasm, + /// The skill may use either runtime depending on the task. + Mixed, +} + +impl SkillRuntime { + fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "host" => Some(Self::Host), + "wasm" => Some(Self::Wasm), + "mixed" | "host+wasm" | "wasm+host" => Some(Self::Mixed), + _ => None, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + SkillRuntime::Host => "host", + SkillRuntime::Wasm => "wasm", + SkillRuntime::Mixed => "mixed", + } + } +} + +/// Parsed skill frontmatter. All optional fields are surfaced so tests +/// can inspect them directly. +#[derive(Debug, Clone)] +pub struct SkillFrontmatter { + pub name: String, + pub description: String, + pub version: Option, + pub triggers: Vec, + pub runtime: SkillRuntime, + pub wasm_module: Option, + pub tools: Vec, +} + +/// A fully loaded desktop skill: frontmatter + Markdown body. +#[derive(Debug, Clone)] +pub struct DesktopSkill { + pub frontmatter: SkillFrontmatter, + pub body: String, +} + +impl DesktopSkill { + pub fn name(&self) -> &str { + self.frontmatter.name.as_str() + } + + pub fn description(&self) -> &str { + self.frontmatter.description.as_str() + } + + pub fn runtime(&self) -> SkillRuntime { + self.frontmatter.runtime + } + + pub fn wasm_module(&self) -> Option<&str> { + self.frontmatter.wasm_module.as_deref() + } + + pub fn tools(&self) -> &[String] { + &self.frontmatter.tools + } + + pub fn body(&self) -> &str { + self.body.as_str() + } + + /// Consume the skill and return only its capability descriptor (the + /// part that is sent to the backend agent). + pub fn into_capability(self) -> DesktopSkillCapability { + DesktopSkillCapability { + name: self.frontmatter.name, + description: self.frontmatter.description, + } + } +} + +/// Parse a Markdown document with YAML-like frontmatter into a skill. +/// +/// The parser accepts the minimal subset of frontmatter used by desktop +/// skills: +/// +/// * A document beginning with `---\n`. +/// * `key: value` pairs until the next `---` line. +/// * Comma-separated values for `triggers` and `tools`. +/// * A Markdown body after the closing `---`. +pub fn parse_skill_document(source: &str) -> Result { + let mut lines = source.lines(); + let first = lines + .next() + .ok_or_else(|| "skill document is empty".to_string())?; + if first.trim() != "---" { + return Err("skill document must start with '---' frontmatter".to_string()); + } + + let mut frontmatter_lines: Vec<&str> = Vec::new(); + let mut closed = false; + for line in lines.by_ref() { + if line.trim() == "---" { + closed = true; + break; + } + frontmatter_lines.push(line); + } + if !closed { + return Err("skill document is missing a closing '---'".to_string()); + } + + let mut name: Option = None; + let mut description: Option = None; + let mut version: Option = None; + let mut triggers: Vec = Vec::new(); + let mut runtime: SkillRuntime = SkillRuntime::Host; + let mut wasm_module: Option = None; + let mut tools: Vec = Vec::new(); + + for raw in frontmatter_lines { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (key, value) = line + .split_once(':') + .ok_or_else(|| format!("invalid frontmatter line '{}'", raw))?; + let key = key.trim().to_ascii_lowercase(); + let value = strip_quotes(value.trim()); + match key.as_str() { + "name" => name = Some(value.to_string()), + "description" => description = Some(value.to_string()), + "version" => version = Some(value.to_string()), + "triggers" => triggers = split_list(value), + "runtime" => { + runtime = SkillRuntime::parse(value).ok_or_else(|| { + format!( + "unknown runtime '{}'. Expected host, wasm, or mixed", + value + ) + })?; + } + "wasm_module" => wasm_module = Some(value.to_string()), + "tools" => tools = split_list(value), + "license" => { /* ignored but accepted for backward compatibility */ } + other => { + return Err(format!("unknown frontmatter key '{}'", other)); + } + } + } + + let name = name.ok_or_else(|| "skill frontmatter is missing 'name'".to_string())?; + let description = description + .ok_or_else(|| "skill frontmatter is missing 'description'".to_string())?; + + let body: String = lines.collect::>().join("\n"); + + Ok(DesktopSkill { + frontmatter: SkillFrontmatter { + name, + description, + version, + triggers, + runtime, + wasm_module, + tools, + }, + body: body.trim_start_matches('\n').to_string(), + }) +} + +fn strip_quotes(value: &str) -> &str { + let value = value.trim(); + if value.len() >= 2 { + let bytes = value.as_bytes(); + let first = bytes[0]; + let last = bytes[value.len() - 1]; + if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') { + return &value[1..value.len() - 1]; + } + } + value +} + +fn split_list(value: &str) -> Vec { + value + .split(',') + .map(|piece| piece.trim().to_string()) + .filter(|piece| !piece.is_empty()) + .collect() +} + +// ----------------------------------------------------------------------- +// Built-in skill registry +// ----------------------------------------------------------------------- + +/// Raw Markdown source for the built-in skills. Bundled into the binary +/// at compile time so the desktop app does not depend on any runtime +/// filesystem layout. Skill files live in +/// `src-tauri/assets/desktop_skills/` and are edited as plain Markdown. +const BUILTIN_PDF_SKILL: &str = include_str!("pdf.skill.md"); +const BUILTIN_DOCX_SKILL: &str = include_str!("docx.skill.md"); +const BUILTIN_XLSX_SKILL: &str = include_str!("xlsx.skill.md"); +const BUILTIN_PPTX_SKILL: &str = include_str!("pptx.skill.md"); + +/// Canonical set of built-in desktop skills. +pub fn common_desktop_skills() -> Vec { + [ + BUILTIN_PDF_SKILL, + BUILTIN_DOCX_SKILL, + BUILTIN_XLSX_SKILL, + BUILTIN_PPTX_SKILL, + ] + .into_iter() + .map(|source| { + parse_skill_document(source) + .unwrap_or_else(|error| panic!("built-in skill failed to parse: {error}")) + }) + .collect() +} + +/// Descriptors for all built-in desktop skills (the lightweight shape +/// that gets shipped to the backend agent). +pub fn common_desktop_skill_capabilities() -> Vec { + common_desktop_skills() + .into_iter() + .map(DesktopSkill::into_capability) + .collect() +} + +/// Look up a built-in desktop skill by name. Returns `None` if the name +/// does not match any registered skill. Used when the agent asks for the +/// body of a skill by name. +pub fn find_builtin_skill(name: &str) -> Option { + let normalized = name.trim().to_ascii_lowercase(); + common_desktop_skills() + .into_iter() + .find(|skill| skill.name().eq_ignore_ascii_case(&normalized)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = "---\n\ +name: sample\n\ +description: A sample skill used only in tests.\n\ +version: 0.1.0\n\ +triggers: foo, bar, baz\n\ +runtime: wasm\n\ +wasm_module: sample_module\n\ +tools: Read, wasm_run\n\ +---\n\ +\n\ +# Sample\n\ +\n\ +Body text.\n"; + + #[test] + fn parses_frontmatter_and_body() { + let skill = parse_skill_document(SAMPLE).expect("parses"); + assert_eq!(skill.name(), "sample"); + assert_eq!(skill.description(), "A sample skill used only in tests."); + assert_eq!(skill.runtime(), SkillRuntime::Wasm); + assert_eq!(skill.wasm_module(), Some("sample_module")); + assert_eq!(skill.tools(), &["Read".to_string(), "wasm_run".to_string()]); + assert_eq!( + skill.frontmatter.triggers, + vec!["foo".to_string(), "bar".to_string(), "baz".to_string()] + ); + assert_eq!(skill.frontmatter.version.as_deref(), Some("0.1.0")); + assert!(skill.body().contains("Body text.")); + } + + #[test] + fn rejects_missing_name() { + let source = "---\ndescription: nope\n---\nBody\n"; + let error = parse_skill_document(source).unwrap_err(); + assert!(error.contains("missing 'name'"), "got: {}", error); + } + + #[test] + fn rejects_bad_runtime() { + let source = "---\nname: x\ndescription: y\nruntime: lua\n---\nBody\n"; + let error = parse_skill_document(source).unwrap_err(); + assert!(error.contains("unknown runtime"), "got: {}", error); + } + + #[test] + fn builtin_skills_parse() { + let skills = common_desktop_skills(); + let names: Vec<_> = skills.iter().map(|s| s.name().to_string()).collect(); + assert_eq!(names, vec!["pdf", "docx", "xlsx", "pptx"]); + for skill in &skills { + assert!(!skill.description().is_empty()); + assert!(!skill.body().is_empty()); + } + } + + #[test] + fn builtin_skills_expose_capabilities() { + let caps = common_desktop_skill_capabilities(); + assert_eq!(caps.len(), 4); + for cap in &caps { + assert!(!cap.name.is_empty()); + assert!(!cap.description.is_empty()); + } + } + + #[test] + fn find_builtin_skill_is_case_insensitive() { + assert!(find_builtin_skill("PDF").is_some()); + assert!(find_builtin_skill("pdf").is_some()); + assert!(find_builtin_skill("nope").is_none()); + } +} diff --git a/frontend/src-tauri/src/cowork/desktop_skills/pdf.skill.md b/frontend/src-tauri/src/cowork/desktop_skills/pdf.skill.md new file mode 100644 index 000000000..f03464972 --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_skills/pdf.skill.md @@ -0,0 +1,157 @@ +--- +name: pdf +description: Desktop PDF processing via an isolated WebAssembly module. Supports plain text extraction and basic metadata. +version: 0.1.0 +runtime: wasm +wasm_module: pdf_processor +--- + +You are about to work with a PDF file inside the selected desktop folder. +Read this entire body, then pick the section that matches the user's +request and copy the `wasm_run` call shape exactly. + +# When to use this skill + +Use it when the task requires reading text, metadata, or structural +information from a `.pdf` file. Do not use it for: + +- plain text, markdown, code, or csv files — use `Read` directly +- moving or renaming pdf files by filename — use `Bash` or `glob` +- tasks where only the filename matters — stay on the host + +# Host first, sandbox second + +Always do cheap host work first and only reach into the sandbox when you +actually need the pdf content: + +1. Use `list_dir` / `glob` to find the pdf files you care about. +2. If the user only cares about filenames or folder structure, answer + from host tools and stop. +3. If you need the pdf's content (to summarise, translate, classify, + extract fields, answer questions about it) call `wasm_run` with one + of the shapes below. +4. Read the result from `output.json` and continue reasoning in the + agent turn. + +The isolated runtime enforces memory, fuel, and wall-clock limits for +you. Trust them — do not try to pre-chunk unless a previous call came +back with an out-of-memory error. + +# Operations + +## extract_text + +Extract one string per page in reading order. Use this for +summarisation, content search, translation, or any task that needs the +raw text of the document. + +Call it with: + +``` +wasm_run({ + "module": "pdf_processor", + "input_json": { "op": "extract_text" }, + "input_files": [ + { "path": "", "name": "input.pdf" } + ] +}) +``` + +The `name` field must be `"input.pdf"` — the pdf_processor module only +reads `/workspace/inputs/input.pdf`. + +On success the tool result contains an `output.json` block of the form: + +``` +{ + "pages": ["page 1 text", "page 2 text", ...], + "page_offset": 1, + "total_pages": +} +``` + +`page_offset` is the 1-based page number of the first entry in `pages`; +when you did not pass a `page_range` it will always be `1`. `total_pages` +is the document's full page count. Join the pages yourself if you need a +single flat string — each page already ends with a newline. + +### Optional page_range + +You may pass `page_range: [start, end]` (1-based, inclusive) inside +`input_json` to extract a subset. Out-of-range requests are clamped: if +a document has 180 pages and you ask for `[201, 250]`, the guest returns +`{"pages": [], "page_offset": 201, "total_pages": 180}` without error. + +Use this only when the user explicitly asks for a specific slice — you +do **not** need to chunk large PDFs by hand. The host automatically +splits very large files into sequential chunks and merges the results +back before returning to you, so you always see a single combined +`pages` array in the tool result (plus a `chunk_summary` section +reporting how many chunks were run). + +``` +wasm_run({ + "module": "pdf_processor", + "input_json": { "op": "extract_text", "page_range": [1, 5] }, + "input_files": [ + { "path": "", "name": "input.pdf" } + ] +}) +``` + +## metadata + +Get the document's title, author, and page count. Use this for +inventory reports, sorting, or to decide whether a document is worth +extracting fully. + +``` +wasm_run({ + "module": "pdf_processor", + "input_json": { "op": "metadata" }, + "input_files": [ + { "path": "", "name": "input.pdf" } + ] +}) +``` + +`output.json` will look like: + +``` +{ "title": "", "author": "", "page_count": } +``` + +# Error handling + +- If `wasm_run` reports `unknown module 'pdf_processor'`, the pdf + runtime has not been shipped with this build. Tell the user that pdf + content extraction is unavailable in this version and offer + filename-level operations instead. +- If `wasm_run` reports a memory limit error, the pdf is too large to + process in a single call. Tell the user the file is oversized and + suggest splitting it manually. Page-range chunking is not yet + supported by the module. +- If `wasm_run` reports an execution error mentioning `load failed` or + `invalid file trailer`, the file is corrupt or not a real pdf. Do not + retry — report the file path and move on. +- Never try to "fix" a pdf by using `Edit` or `Write` on it. pdf is a + binary format and any byte-level edit will corrupt it. + +# Multi-file workflow + +For batch jobs (for example "summarise every pdf in this folder"): + +1. `glob` pattern `**/*.pdf` to list the files. +2. Call `wasm_run` with `op: extract_text` once per file. +3. Between calls, keep per-file results in your reasoning — do not try + to pass multiple pdfs into a single wasm_run call. +4. When you have all the text, compose the final answer. + +# What to return to the user + +Always tell the user: + +- how many files you inspected +- which operation(s) you ran against each +- a short summary of the extracted content, grouped by file path +- any files that failed, with the exact error message diff --git a/frontend/src-tauri/src/cowork/desktop_skills/pptx.skill.md b/frontend/src-tauri/src/cowork/desktop_skills/pptx.skill.md new file mode 100644 index 000000000..2a062d793 --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_skills/pptx.skill.md @@ -0,0 +1,83 @@ +--- +name: pptx +description: Desktop presentation handling via an isolated WebAssembly module. Supports slide text extraction and slide counting. +version: 0.1.0 +runtime: wasm +wasm_module: pptx_processor +--- + +You are about to work with PowerPoint files inside the selected desktop +folder. Read this body, then pick the operation that matches the task. + +# When to use this skill + +Use when the task requires reading slide text from a `.pptx` file. +Do NOT use for plain text/md/txt files (use `Read` directly) or for +tasks that only care about filenames (use `glob`/`list_dir`). + +# Host first, sandbox second + +1. Use `glob` / `list_dir` to find `.pptx` files. +2. If the task only needs filenames → answer from host tools. +3. If you need slide content → call `wasm_run` below. +4. Never `Read` or `Edit` a `.pptx` file — it is a zip container. + +# Operations + +## extract_text + +Extract all slide text in slide order. + +``` +wasm_run({ + "module": "pptx_processor", + "input_json": { "op": "extract_text" }, + "input_files": [{ "path": "", "name": "input.pptx" }] +}) +``` + +Response `output.json`: +``` +{ + "slides": [{"index": 1, "text": "..."}, {"index": 2, "text": "..."}, ...], + "slide_offset": 1, + "total_slides": N +} +``` + +### Optional slide_range + +``` +wasm_run({ + "module": "pptx_processor", + "input_json": { "op": "extract_text", "slide_range": [1, 10] }, + "input_files": [{ "path": "", "name": "input.pptx" }] +}) +``` + +Out-of-range requests are clamped. Large files are automatically chunked +by the host. + +## count_slides + +``` +wasm_run({ + "module": "pptx_processor", + "input_json": { "op": "count_slides" }, + "input_files": [{ "path": "", "name": "input.pptx" }] +}) +``` + +Response: `{ "total_slides": N }` + +# Error handling + +- Memory limit error → file too large, report to user. +- `pptx zip open failed` → corrupt file, do not retry. +- Never try to edit a `.pptx` with `Edit`. + +# What to return to the user + +- Files inspected and operations run. +- Per-slide text summaries when applicable. +- Any errors encountered with file paths. diff --git a/frontend/src-tauri/src/cowork/desktop_skills/xlsx.skill.md b/frontend/src-tauri/src/cowork/desktop_skills/xlsx.skill.md new file mode 100644 index 000000000..546375c3c --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_skills/xlsx.skill.md @@ -0,0 +1,98 @@ +--- +name: xlsx +description: Desktop spreadsheet handling. CSV/TSV tasks use host tools directly. XLSX tasks use the isolated xlsx_processor WebAssembly module. +version: 0.1.0 +runtime: mixed +wasm_module: xlsx_processor +--- + +You are about to work with spreadsheet files inside the selected desktop +folder. Read this body carefully — this skill splits cleanly between a +host path (CSV/TSV) and a sandbox path (XLSX) and the two should never +be confused. + +# Decision tree + +1. Is the file `.csv` or `.tsv`? + → Use `Read` and `Edit` directly. These are plain text files. Do + **not** call `wasm_run`. +2. Is the file `.xlsx` or `.xlsm`? + → Content operations need the `xlsx_processor` WebAssembly module. + That module is **not shipped** in this build (see below). +3. Is the task only filename-level? + → Use `glob` / `list_dir` / `Bash` for both csv and xlsx. + +# CSV / TSV — host path (works today) + +Treat these as plain text. Typical tasks: + +- `Read` a csv to show the first few rows +- `Edit` or `Write` to modify cells +- `grep` for a column value across many csv files +- `Bash` for batch rename or move + +Never try to run a csv through `wasm_run` — there is no value in +isolating plain text parsing. + +# XLSX / XLSM — sandbox path (shipped) + +## list_sheets + +Get an inventory of all sheets in the workbook with row/col counts. + +``` +wasm_run({ + "module": "xlsx_processor", + "input_json": { "op": "list_sheets" }, + "input_files": [{ "path": "", "name": "input.xlsx" }] +}) +``` + +Response: `{ "sheets": [{"name": "Sheet1", "rows": 500, "cols": 12}, ...] }` + +## read_sheet + +Read all cell values from a named sheet. + +``` +wasm_run({ + "module": "xlsx_processor", + "input_json": { "op": "read_sheet", "sheet": "" }, + "input_files": [{ "path": "", "name": "input.xlsx" }] +}) +``` + +Response: `{ "sheet": "...", "rows": [[cell, ...], ...], "row_offset": 1, "total_rows": N }` + +### Optional row_range + +``` +wasm_run({ + "module": "xlsx_processor", + "input_json": { "op": "read_sheet", "sheet": "Data", "row_range": [1, 100] }, + "input_files": [{ "path": "", "name": "input.xlsx" }] +}) +``` + +Out-of-range requests are clamped. Large files are automatically chunked +by the host and merged. + +Do not try to open an `.xlsx` file with `Read` — it is a zipped OOXML +container. Do not try to `Edit` it — you will corrupt the zip. + +# Formula safety (when the module ships) + +When writing back cell values via `update_cells`, always: + +1. `read_sheet` first to see whether the target cell already holds a + formula. +2. If it does, ask the user before overwriting it with a plain value. +3. Never silently "fix" `#REF!`, `#N/A`, or `#VALUE!` — surface them in + your summary. + +# What to return to the user + +- The list of spreadsheet files you inspected. +- Which path you took (host-csv vs sandbox-xlsx). +- Any operations that were gated on the missing xlsx module. +- Paths of any files you wrote back. diff --git a/frontend/src-tauri/src/cowork/desktop_tools/apply_patch.rs b/frontend/src-tauri/src/cowork/desktop_tools/apply_patch.rs new file mode 100644 index 000000000..48f1bcf84 --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_tools/apply_patch.rs @@ -0,0 +1,263 @@ +use super::{DesktopTool, DesktopToolContext, TOOL_APPLY_PATCH}; +use crate::cowork::agent_presets::shared::DesktopToolCapability; +use serde_json::{json, Value}; +use std::path::Path; + +pub fn desktop_tool() -> DesktopTool { + DesktopTool::new( + DesktopToolCapability { + name: TOOL_APPLY_PATCH.to_string(), + aliases: Vec::new(), + display_name: "Apply local patch".to_string(), + description: "Apply a structured patch to local files inside the selected desktop folder. Use absolute paths in patch headers.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "The patch envelope starting with *** Begin Patch and ending with *** End Patch." + } + }, + "required": ["input"] + }), + }, + execute, + true, + ) +} + +fn execute(ctx: &mut DesktopToolContext<'_>, tool_input: &Value) -> Result { + let input = tool_input + .get("input") + .and_then(Value::as_str) + .ok_or_else(|| "apply_patch requires input".to_string())?; + let operations = parse_patch_operations(input)?; + let mut summaries = Vec::new(); + for operation in operations { + match operation { + PatchOperation::AddFile { path, lines } => { + let resolved_path = ctx.resolve_scoped_path(&path, true)?; + if let Some(parent) = resolved_path.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + format!( + "Failed to create parent directory {}: {}", + parent.display(), + error + ) + })?; + } + std::fs::write(&resolved_path, lines.join("\n")).map_err(|error| { + format!("Failed to create {}: {}", resolved_path.display(), error) + })?; + summaries.push(format!("Added {}", resolved_path.display())); + } + PatchOperation::DeleteFile { path } => { + let resolved_path = ctx.resolve_scoped_path(&path, false)?; + std::fs::remove_file(&resolved_path).map_err(|error| { + format!("Failed to delete {}: {}", resolved_path.display(), error) + })?; + summaries.push(format!("Deleted {}", resolved_path.display())); + } + PatchOperation::UpdateFile { + path, + move_to, + hunks, + } => { + let resolved_path = ctx.resolve_scoped_path(&path, false)?; + let contents = std::fs::read_to_string(&resolved_path).map_err(|error| { + format!("Failed to read {}: {}", resolved_path.display(), error) + })?; + let mut lines = contents + .lines() + .map(ToString::to_string) + .collect::>(); + let mut cursor = 0usize; + for hunk in hunks { + cursor = apply_patch_hunk(&mut lines, &hunk, cursor, &resolved_path)?; + } + + let output_path = move_to + .as_deref() + .map(|target| ctx.resolve_scoped_path(target, true)) + .transpose()? + .unwrap_or_else(|| resolved_path.clone()); + if let Some(parent) = output_path.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + format!( + "Failed to create parent directory {}: {}", + parent.display(), + error + ) + })?; + } + std::fs::write(&output_path, lines.join("\n")).map_err(|error| { + format!("Failed to write {}: {}", output_path.display(), error) + })?; + if output_path != resolved_path { + let _ = std::fs::remove_file(&resolved_path); + summaries.push(format!( + "Updated {} and moved to {}", + resolved_path.display(), + output_path.display() + )); + } else { + summaries.push(format!("Updated {}", output_path.display())); + } + } + } + } + + Ok(summaries.join("\n")) +} + +enum PatchOperation { + AddFile { + path: String, + lines: Vec, + }, + DeleteFile { + path: String, + }, + UpdateFile { + path: String, + move_to: Option, + hunks: Vec>, + }, +} + +fn parse_patch_operations(input: &str) -> Result, String> { + let lines = input.lines().collect::>(); + if lines.first().copied() != Some("*** Begin Patch") { + return Err("apply_patch input must start with *** Begin Patch".to_string()); + } + if lines.last().copied() != Some("*** End Patch") { + return Err("apply_patch input must end with *** End Patch".to_string()); + } + + let mut operations = Vec::new(); + let mut index = 1usize; + while index + 1 < lines.len() { + let line = lines[index]; + if let Some(path) = line.strip_prefix("*** Add File: ") { + index += 1; + let mut add_lines = Vec::new(); + while index < lines.len() && !lines[index].starts_with("*** ") { + let content = lines[index] + .strip_prefix('+') + .ok_or_else(|| "apply_patch Add File lines must start with +".to_string())?; + add_lines.push(content.to_string()); + index += 1; + } + operations.push(PatchOperation::AddFile { + path: path.to_string(), + lines: add_lines, + }); + continue; + } + + if let Some(path) = line.strip_prefix("*** Delete File: ") { + operations.push(PatchOperation::DeleteFile { + path: path.to_string(), + }); + index += 1; + continue; + } + + if let Some(path) = line.strip_prefix("*** Update File: ") { + index += 1; + let mut move_to = None; + if index < lines.len() { + if let Some(target) = lines[index].strip_prefix("*** Move to: ") { + move_to = Some(target.to_string()); + index += 1; + } + } + + let mut hunks = Vec::new(); + while index < lines.len() && !lines[index].starts_with("*** ") { + if lines[index].starts_with("@@") { + index += 1; + let mut hunk_lines = Vec::new(); + while index < lines.len() + && !lines[index].starts_with("@@") + && !lines[index].starts_with("*** ") + { + if lines[index] == "*** End of File" { + index += 1; + break; + } + hunk_lines.push(lines[index].to_string()); + index += 1; + } + hunks.push(hunk_lines); + continue; + } + return Err(format!("Unexpected apply_patch line: {}", lines[index])); + } + + operations.push(PatchOperation::UpdateFile { + path: path.to_string(), + move_to, + hunks, + }); + continue; + } + + return Err(format!("Unsupported apply_patch header: {}", line)); + } + + Ok(operations) +} + +fn apply_patch_hunk( + lines: &mut Vec, + hunk_lines: &[String], + start_cursor: usize, + file_path: &Path, +) -> Result { + let mut old_block = Vec::new(); + let mut new_block = Vec::new(); + for line in hunk_lines { + let (prefix, content) = line.split_at(1); + match prefix { + " " => { + old_block.push(content.to_string()); + new_block.push(content.to_string()); + } + "-" => old_block.push(content.to_string()), + "+" => new_block.push(content.to_string()), + _ => return Err(format!("Unsupported apply_patch hunk line: {}", line)), + } + } + + if old_block.is_empty() { + return Err(format!( + "apply_patch hunks without context are not supported for {}", + file_path.display() + )); + } + + let match_index = find_line_block(lines, &old_block, start_cursor).ok_or_else(|| { + format!( + "apply_patch could not match hunk in {}", + file_path.display() + ) + })?; + lines.splice( + match_index..match_index + old_block.len(), + new_block.clone(), + ); + Ok(match_index + new_block.len()) +} + +fn find_line_block(lines: &[String], block: &[String], start_cursor: usize) -> Option { + if block.len() > lines.len() { + return None; + } + for start in start_cursor..=lines.len().saturating_sub(block.len()) { + if lines[start..start + block.len()] == *block { + return Some(start); + } + } + None +} diff --git a/frontend/src-tauri/src/cowork/desktop_tools/bash.rs b/frontend/src-tauri/src/cowork/desktop_tools/bash.rs new file mode 100644 index 000000000..dcc9fc311 --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_tools/bash.rs @@ -0,0 +1,292 @@ +use super::{DesktopBashSession, DesktopTool, DesktopToolContext, TOOL_BASH}; +use crate::cowork::agent_presets::shared::DesktopToolCapability; +use serde_json::{json, Value}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; + +#[cfg(target_os = "windows")] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +const DEFAULT_BASH_TIMEOUT_SECS: u64 = 60; +const MAX_BASH_TIMEOUT_SECS: u64 = 180; + +pub fn desktop_tool() -> DesktopTool { + DesktopTool::new( + DesktopToolCapability { + name: TOOL_BASH.to_string(), + aliases: Vec::new(), + display_name: "Run or inspect desktop shell sessions".to_string(), + description: "Run a local shell command or inspect saved shell session output inside the selected desktop folder. Use action='run' to execute commands, action='view' to inspect saved sessions, or action='list_sessions' to list available sessions. This tool runs on the desktop app, not on the Python backend.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["run", "view", "list_sessions"], + "description": "The Bash action to perform. Defaults to 'run'." + }, + "session_name": { + "type": "string", + "description": "Logical shell session name used to store or inspect command output. Defaults to 'default'." + }, + "session_names": { + "type": "array", + "items": {"type": "string"}, + "description": "Session names to inspect when action='view'." + }, + "command": { + "type": "string", + "description": "The shell command to execute when action='run'." + }, + "cwd": { + "type": "string", + "description": "Optional working directory for action='run' within the selected desktop scope. It may be absolute or relative to the scope root." + }, + "shell": { + "type": "string", + "description": "Optional shell executable for action='run'. Supported values include powershell, pwsh, cmd, sh, bash, and zsh. Defaults to powershell on Windows and sh on other systems." + }, + "description": { + "type": "string", + "description": "Optional short description of what the command does when action='run'." + }, + "wait_for_output": { + "type": "boolean", + "description": "Whether to wait for command completion when action='run'. Defaults to true. Background execution is not supported yet." + }, + "timeout": { + "type": "integer", + "description": "Optional timeout in seconds for action='run'. Defaults to 60 and is capped at 180." + } + }, + "required": [] + }), + }, + execute, + true, + ) + .with_aliases(&["bash"]) +} + +fn execute(ctx: &mut DesktopToolContext<'_>, tool_input: &Value) -> Result { + let action = tool_input + .get("action") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("run"); + + match action { + "run" => execute_run(ctx, tool_input), + "view" => execute_view(ctx, tool_input), + "list_sessions" => execute_list_sessions(ctx), + other => Err(format!( + "Unsupported Bash action '{}'. Supported actions: run, view, list_sessions", + other + )), + } +} + +fn execute_run(ctx: &mut DesktopToolContext<'_>, tool_input: &Value) -> Result { + let session_name = tool_input + .get("session_name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("default") + .to_string(); + let command = tool_input + .get("command") + .and_then(Value::as_str) + .ok_or_else(|| "Bash action='run' requires command".to_string())?; + let wait_for_output = tool_input + .get("wait_for_output") + .and_then(Value::as_bool) + .unwrap_or(true); + if !wait_for_output { + return Err( + "Background Bash execution is not supported in desktop cowork mode yet".to_string(), + ); + } + + let timeout_secs = tool_input + .get("timeout") + .and_then(Value::as_u64) + .unwrap_or(DEFAULT_BASH_TIMEOUT_SECS) + .min(MAX_BASH_TIMEOUT_SECS); + let working_directory = tool_input + .get("cwd") + .and_then(Value::as_str) + .map(|value| ctx.resolve_scoped_path(value, false)) + .transpose()? + .unwrap_or_else(|| ctx.working_directory().to_path_buf()); + let requested_shell = tool_input + .get("shell") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + + let mut process = build_command_process(requested_shell, command, &working_directory)?; + process.stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut child = process + .spawn() + .map_err(|error| format!("Failed to start Bash command: {error}"))?; + let deadline = Instant::now() + Duration::from_secs(timeout_secs); + + loop { + if child + .try_wait() + .map_err(|error| format!("Failed to poll Bash command: {error}"))? + .is_some() + { + let output = child + .wait_with_output() + .map_err(|error| format!("Failed to collect Bash output: {error}"))?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let combined = format_output(stdout.as_ref(), stderr.as_ref(), output.status.code()); + ctx.bash_sessions_mut().insert( + session_name, + DesktopBashSession { + last_output: combined.clone(), + last_exit_code: output.status.code(), + }, + ); + if output.status.success() { + return Ok(combined); + } + return Err(combined); + } + + if Instant::now() >= deadline { + let _ = child.kill(); + let output = child + .wait_with_output() + .map_err(|error| format!("Failed to collect timed out Bash output: {error}"))?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let combined = format!( + "{}\nTimed out after {} seconds", + format_output(stdout.as_ref(), stderr.as_ref(), output.status.code()), + timeout_secs + ); + ctx.bash_sessions_mut().insert( + session_name, + DesktopBashSession { + last_output: combined.clone(), + last_exit_code: output.status.code(), + }, + ); + return Err(combined); + } + + thread::sleep(Duration::from_millis(100)); + } +} + +fn execute_view(ctx: &mut DesktopToolContext<'_>, tool_input: &Value) -> Result { + let session_names = tool_input + .get("session_names") + .and_then(Value::as_array) + .ok_or_else(|| "Bash action='view' requires session_names".to_string())?; + let mut results = Vec::new(); + for session_name in session_names { + let Some(session_name) = session_name.as_str() else { + continue; + }; + if let Some(state) = ctx.bash_sessions().get(session_name) { + results.push(format!( + "[{}] exit_code={:?}\n{}", + session_name, state.last_exit_code, state.last_output + )); + } else { + results.push(format!( + "[{}] No desktop bash session output available", + session_name + )); + } + } + Ok(results.join("\n\n")) +} + +fn execute_list_sessions(ctx: &mut DesktopToolContext<'_>) -> Result { + let session_names = ctx.bash_sessions().keys().cloned().collect::>(); + serde_json::to_string_pretty(&session_names) + .map_err(|error| format!("Failed to encode bash session list: {error}")) +} + +fn format_output(stdout: &str, stderr: &str, exit_code: Option) -> String { + let mut sections = Vec::new(); + if !stdout.trim().is_empty() { + sections.push(format!("stdout:\n{}", stdout.trim_end())); + } + if !stderr.trim().is_empty() { + sections.push(format!("stderr:\n{}", stderr.trim_end())); + } + sections.push(format!("exit_code: {:?}", exit_code)); + sections.join("\n\n") +} + +fn build_command_process( + requested_shell: Option<&str>, + command: &str, + working_directory: &PathBuf, +) -> Result { + let shell = requested_shell.unwrap_or(default_shell_name()); + let mut process = match shell { + "powershell" => { + let mut command_builder = Command::new("powershell"); + command_builder + .arg("-NoProfile") + .arg("-Command") + .arg(command); + command_builder + } + "pwsh" => { + let mut command_builder = Command::new("pwsh"); + command_builder + .arg("-NoProfile") + .arg("-Command") + .arg(command); + command_builder + } + "cmd" => { + let mut command_builder = Command::new("cmd"); + command_builder.arg("/C").arg(command); + command_builder + } + "sh" | "bash" | "zsh" => { + let mut command_builder = Command::new(shell); + command_builder.arg("-lc").arg(command); + command_builder + } + _ => { + return Err(format!( + "Unsupported shell '{}'. Supported shells: powershell, pwsh, cmd, sh, bash, zsh", + shell + )) + } + }; + + process.current_dir(working_directory); + #[cfg(target_os = "windows")] + { + // Keep shell execution headless in desktop mode to avoid flashing terminal windows. + process.creation_flags(CREATE_NO_WINDOW); + } + + Ok(process) +} + +fn default_shell_name() -> &'static str { + if cfg!(target_os = "windows") { + "powershell" + } else { + "sh" + } +} diff --git a/frontend/src-tauri/src/cowork/desktop_tools/edit.rs b/frontend/src-tauri/src/cowork/desktop_tools/edit.rs new file mode 100644 index 000000000..e81def53d --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_tools/edit.rs @@ -0,0 +1,90 @@ +use super::{DesktopTool, DesktopToolContext, TOOL_EDIT}; +use crate::cowork::agent_presets::shared::DesktopToolCapability; +use serde_json::{json, Value}; + +pub fn desktop_tool() -> DesktopTool { + DesktopTool::new( + DesktopToolCapability { + name: TOOL_EDIT.to_string(), + aliases: Vec::new(), + display_name: "Edit local file".to_string(), + description: "Perform an exact string replacement inside a local file within the selected desktop folder.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "The file path to edit. It may be absolute or relative to the current desktop scope." + }, + "path": { + "type": "string", + "description": "Alias for file_path. It may be absolute or relative to the current desktop scope." + }, + "old_string": { + "type": "string", + "description": "The exact text to replace." + }, + "new_string": { + "type": "string", + "description": "The replacement text." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches instead of only the first unique match." + } + }, + "required": ["old_string", "new_string"] + }), + }, + execute, + true, + ) + .with_aliases(&["edit_file"]) +} + +fn execute(ctx: &mut DesktopToolContext<'_>, tool_input: &Value) -> Result { + let file_path = tool_input + .get("file_path") + .or_else(|| tool_input.get("path")) + .and_then(Value::as_str) + .ok_or_else(|| "Edit requires file_path or path".to_string())?; + let old_string = tool_input + .get("old_string") + .and_then(Value::as_str) + .ok_or_else(|| "Edit requires old_string".to_string())?; + let new_string = tool_input + .get("new_string") + .and_then(Value::as_str) + .ok_or_else(|| "Edit requires new_string".to_string())?; + let replace_all = tool_input + .get("replace_all") + .and_then(Value::as_bool) + .unwrap_or(false); + let resolved_path = ctx.resolve_scoped_path(file_path, false)?; + let contents = std::fs::read_to_string(&resolved_path) + .map_err(|error| format!("Failed to read {}: {}", resolved_path.display(), error))?; + + let match_count = contents.matches(old_string).count(); + if match_count == 0 { + return Err(format!( + "Edit could not find the requested text in {}", + resolved_path.display() + )); + } + if !replace_all && match_count > 1 { + return Err(format!( + "Edit found {} matches in {}; use replace_all or provide more context", + match_count, + resolved_path.display() + )); + } + + let updated = if replace_all { + contents.replace(old_string, new_string) + } else { + contents.replacen(old_string, new_string, 1) + }; + std::fs::write(&resolved_path, updated) + .map_err(|error| format!("Failed to write {}: {}", resolved_path.display(), error))?; + Ok(format!("Edited {}", resolved_path.display())) +} diff --git a/frontend/src-tauri/src/cowork/desktop_tools/glob.rs b/frontend/src-tauri/src/cowork/desktop_tools/glob.rs new file mode 100644 index 000000000..a536ac5e0 --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_tools/glob.rs @@ -0,0 +1,181 @@ +use super::{DesktopTool, DesktopToolContext, TOOL_GLOB}; +use crate::cowork::agent_presets::shared::DesktopToolCapability; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +const DEFAULT_LIMIT: usize = 100; +const COMMON_SKIP_DIRS: &[&str] = &[".git", "node_modules", "target", "__pycache__"]; + +pub fn desktop_tool() -> DesktopTool { + DesktopTool::new( + DesktopToolCapability { + name: TOOL_GLOB.to_string(), + aliases: Vec::new(), + display_name: "Find local files by glob".to_string(), + description: "Find files inside the selected local desktop folder using a glob pattern such as '**/*.rs' or 'src/**/*.ts'. This runs on the desktop app.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "The glob pattern to match relative to the chosen search path, for example '**/*.rs'." + }, + "path": { + "type": "string", + "description": "Optional base directory path within the selected desktop scope. It may be absolute or relative to the scope root." + }, + "limit": { + "type": "integer", + "description": "Maximum number of matches to return. Defaults to 100." + }, + "include_hidden": { + "type": "boolean", + "description": "If true, include hidden files and common ignored directories. Defaults to false." + } + }, + "required": ["pattern"] + }), + }, + execute, + false, + ) +} + +fn execute(ctx: &mut DesktopToolContext<'_>, tool_input: &Value) -> Result { + let pattern = tool_input + .get("pattern") + .and_then(Value::as_str) + .ok_or_else(|| "glob requires pattern".to_string())? + .trim(); + if pattern.is_empty() { + return Err("glob pattern cannot be empty".to_string()); + } + + let matcher = + ::glob::Pattern::new(pattern).map_err(|error| format!("Invalid glob pattern: {error}"))?; + let search_root = resolve_search_root(ctx, tool_input.get("path").and_then(Value::as_str))?; + let limit = tool_input + .get("limit") + .and_then(Value::as_u64) + .unwrap_or(DEFAULT_LIMIT as u64) as usize; + let include_hidden = tool_input + .get("include_hidden") + .and_then(Value::as_bool) + .unwrap_or(false); + + if limit == 0 { + return Err("glob limit must be 1 or greater".to_string()); + } + + let mut matches = Vec::new(); + let mut truncated = false; + collect_matches( + &search_root, + &search_root, + &matcher, + include_hidden, + limit, + &mut matches, + &mut truncated, + )?; + + if matches.is_empty() { + return Ok(format!("No local files matched glob pattern: {pattern}")); + } + + if truncated { + matches.push(format!("[glob truncated at {} results]", limit)); + } + + Ok(matches.join("\n")) +} + +fn resolve_search_root( + ctx: &DesktopToolContext<'_>, + path: Option<&str>, +) -> Result { + let resolved = match path { + Some(value) => ctx.resolve_scoped_path(value, false)?, + None => ctx.working_directory().to_path_buf(), + }; + + if !resolved.is_dir() { + return Err(format!( + "glob path is not a directory: {}", + resolved.display() + )); + } + + Ok(resolved) +} + +#[allow(clippy::too_many_arguments)] +fn collect_matches( + root: &Path, + current: &Path, + matcher: &::glob::Pattern, + include_hidden: bool, + limit: usize, + matches: &mut Vec, + truncated: &mut bool, +) -> Result<(), String> { + if matches.len() >= limit { + *truncated = true; + return Ok(()); + } + + let entries = fs::read_dir(current) + .map_err(|error| format!("Failed to read directory {}: {}", current.display(), error))?; + + for entry_result in entries { + let entry = + entry_result.map_err(|error| format!("Failed to read directory entry: {}", error))?; + let path = entry.path(); + let file_name = entry.file_name().to_string_lossy().to_string(); + if should_skip_name(&file_name, include_hidden) { + continue; + } + + let relative_path = path + .strip_prefix(root) + .map_err(|error| format!("Failed to compute relative path: {error}"))?; + let relative_pattern_path = normalize_path_for_pattern(relative_path); + if path.is_file() && matcher.matches(&relative_pattern_path) { + if matches.len() < limit { + matches.push(path.display().to_string()); + } else { + *truncated = true; + } + } + + if path.is_dir() { + collect_matches( + root, + &path, + matcher, + include_hidden, + limit, + matches, + truncated, + )?; + if matches.len() >= limit { + break; + } + } + } + + Ok(()) +} + +fn should_skip_name(name: &str, include_hidden: bool) -> bool { + if include_hidden { + return false; + } + + name.starts_with('.') || COMMON_SKIP_DIRS.iter().any(|value| value == &name) +} + +fn normalize_path_for_pattern(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} diff --git a/frontend/src-tauri/src/cowork/desktop_tools/grep.rs b/frontend/src-tauri/src/cowork/desktop_tools/grep.rs new file mode 100644 index 000000000..5eb034c89 --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_tools/grep.rs @@ -0,0 +1,258 @@ +use super::{DesktopTool, DesktopToolContext, TOOL_GREP}; +use crate::cowork::agent_presets::shared::DesktopToolCapability; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +const DEFAULT_MATCH_LIMIT: usize = 50; +const COMMON_SKIP_DIRS: &[&str] = &[".git", "node_modules", "target", "__pycache__"]; + +pub fn desktop_tool() -> DesktopTool { + DesktopTool::new( + DesktopToolCapability { + name: TOOL_GREP.to_string(), + aliases: Vec::new(), + display_name: "Search local files by regex".to_string(), + description: "Search local files inside the selected desktop folder using a regex pattern. Returns absolute file paths with line numbers. This runs on the desktop app.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "The regex pattern to search for." + }, + "path": { + "type": "string", + "description": "Optional file or directory path within the selected desktop scope. It may be absolute or relative to the scope root." + }, + "glob": { + "type": "string", + "description": "Optional glob filter relative to the search directory, for example '**/*.rs' or '*.ts'." + }, + "case_insensitive": { + "type": "boolean", + "description": "If true, perform a case-insensitive regex search." + }, + "context": { + "type": "integer", + "description": "Number of context lines before and after each match. Defaults to 0." + }, + "limit": { + "type": "integer", + "description": "Maximum number of regex matches to return. Defaults to 50." + }, + "include_hidden": { + "type": "boolean", + "description": "If true, include hidden files and common ignored directories. Defaults to false." + } + }, + "required": ["pattern"] + }), + }, + execute, + false, + ) +} + +fn execute(ctx: &mut DesktopToolContext<'_>, tool_input: &Value) -> Result { + let pattern = tool_input + .get("pattern") + .and_then(Value::as_str) + .ok_or_else(|| "grep requires pattern".to_string())?; + let regex = build_regex(pattern, tool_input)?; + let search_path = resolve_search_path(ctx, tool_input.get("path").and_then(Value::as_str))?; + let matcher = tool_input + .get("glob") + .and_then(Value::as_str) + .map(::glob::Pattern::new) + .transpose() + .map_err(|error| format!("Invalid grep glob filter: {error}"))?; + let context_lines = tool_input + .get("context") + .and_then(Value::as_u64) + .unwrap_or(0) as usize; + let limit = tool_input + .get("limit") + .and_then(Value::as_u64) + .unwrap_or(DEFAULT_MATCH_LIMIT as u64) as usize; + let include_hidden = tool_input + .get("include_hidden") + .and_then(Value::as_bool) + .unwrap_or(false); + + if limit == 0 { + return Err("grep limit must be 1 or greater".to_string()); + } + + let mut results = Vec::new(); + let mut match_count = 0usize; + + if search_path.is_file() { + search_file( + &search_path, + ®ex, + context_lines, + limit, + &mut match_count, + &mut results, + )?; + } else { + search_directory( + &search_path, + &search_path, + ®ex, + matcher.as_ref(), + context_lines, + limit, + include_hidden, + &mut match_count, + &mut results, + )?; + } + + if results.is_empty() { + return Ok(format!("No local matches found for regex: {pattern}")); + } + + Ok(results.join("\n")) +} + +fn build_regex(pattern: &str, tool_input: &Value) -> Result { + let case_insensitive = tool_input + .get("case_insensitive") + .and_then(Value::as_bool) + .unwrap_or(false); + regex::RegexBuilder::new(pattern) + .case_insensitive(case_insensitive) + .build() + .map_err(|error| format!("Invalid regex pattern: {error}")) +} + +fn resolve_search_path( + ctx: &DesktopToolContext<'_>, + path: Option<&str>, +) -> Result { + match path { + Some(value) => ctx.resolve_scoped_path(value, false), + None => Ok(ctx.working_directory().to_path_buf()), + } +} + +#[allow(clippy::too_many_arguments)] +fn search_directory( + root: &Path, + current: &Path, + regex: ®ex::Regex, + matcher: Option<&::glob::Pattern>, + context_lines: usize, + limit: usize, + include_hidden: bool, + match_count: &mut usize, + results: &mut Vec, +) -> Result<(), String> { + if *match_count >= limit { + return Ok(()); + } + + let entries = fs::read_dir(current) + .map_err(|error| format!("Failed to read directory {}: {}", current.display(), error))?; + + for entry_result in entries { + let entry = + entry_result.map_err(|error| format!("Failed to read directory entry: {}", error))?; + let path = entry.path(); + let file_name = entry.file_name().to_string_lossy().to_string(); + if should_skip_name(&file_name, include_hidden) { + continue; + } + + if path.is_dir() { + search_directory( + root, + &path, + regex, + matcher, + context_lines, + limit, + include_hidden, + match_count, + results, + )?; + } else if matches_glob(root, &path, matcher)? { + search_file(&path, regex, context_lines, limit, match_count, results)?; + } + + if *match_count >= limit { + break; + } + } + + Ok(()) +} + +fn search_file( + path: &Path, + regex: ®ex::Regex, + context_lines: usize, + limit: usize, + match_count: &mut usize, + results: &mut Vec, +) -> Result<(), String> { + let content = match fs::read_to_string(path) { + Ok(value) => value, + Err(_) => return Ok(()), + }; + let lines = content.lines().collect::>(); + + for (index, line) in lines.iter().enumerate() { + if !regex.is_match(line) { + continue; + } + + *match_count += 1; + if *match_count > limit { + break; + } + + let start = index.saturating_sub(context_lines); + let end = (index + context_lines + 1).min(lines.len()); + for line_index in start..end { + let marker = if line_index == index { ">" } else { ":" }; + results.push(format!( + "{}:{}{} {}", + path.display(), + line_index + 1, + marker, + lines[line_index] + )); + } + if context_lines > 0 { + results.push("---".to_string()); + } + } + + Ok(()) +} + +fn matches_glob( + root: &Path, + path: &Path, + matcher: Option<&::glob::Pattern>, +) -> Result { + let Some(matcher) = matcher else { + return Ok(true); + }; + + let relative_path = path + .strip_prefix(root) + .map_err(|error| format!("Failed to compute relative path: {error}"))?; + Ok(matcher.matches(&relative_path.to_string_lossy().replace('\\', "/"))) +} + +fn should_skip_name(name: &str, include_hidden: bool) -> bool { + if include_hidden { + return false; + } + + name.starts_with('.') || COMMON_SKIP_DIRS.iter().any(|value| value == &name) +} diff --git a/frontend/src-tauri/src/cowork/desktop_tools/list_dir.rs b/frontend/src-tauri/src/cowork/desktop_tools/list_dir.rs new file mode 100644 index 000000000..6d7c71666 --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_tools/list_dir.rs @@ -0,0 +1,266 @@ +use super::{DesktopTool, DesktopToolContext, TOOL_LIST_DIR}; +use crate::cowork::agent_presets::shared::DesktopToolCapability; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +const DEFAULT_MAX_DEPTH: usize = 3; +const DEFAULT_LIMIT: usize = 500; +const COMMON_SKIP_DIRS: &[&str] = &[".git", "node_modules", "target", "__pycache__"]; + +pub fn desktop_tool() -> DesktopTool { + DesktopTool::new( + DesktopToolCapability { + name: TOOL_LIST_DIR.to_string(), + aliases: Vec::new(), + display_name: "List local directory".to_string(), + description: "List files and subdirectories inside the selected local desktop folder. Supports recursive listing and returns scoped absolute paths.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Optional directory path within the selected desktop scope. It may be absolute or relative to the current scope root." + }, + "recursive": { + "type": "boolean", + "description": "If true, list contents recursively. Defaults to false." + }, + "max_depth": { + "type": "integer", + "description": "Maximum depth for recursive listing. Defaults to 3." + }, + "limit": { + "type": "integer", + "description": "Maximum number of entries to return. Defaults to 500." + }, + "include_hidden": { + "type": "boolean", + "description": "If true, include hidden files and common ignored directories. Defaults to false." + } + }, + "required": [] + }), + }, + execute, + false, + ) +} + +fn execute(ctx: &mut DesktopToolContext<'_>, tool_input: &Value) -> Result { + let directory_path = resolve_directory(ctx, tool_input.get("path").and_then(Value::as_str))?; + let recursive = tool_input + .get("recursive") + .and_then(Value::as_bool) + .unwrap_or(false); + let include_hidden = tool_input + .get("include_hidden") + .and_then(Value::as_bool) + .unwrap_or(false); + let max_depth = tool_input + .get("max_depth") + .and_then(Value::as_u64) + .unwrap_or(DEFAULT_MAX_DEPTH as u64) as usize; + let limit = tool_input + .get("limit") + .and_then(Value::as_u64) + .unwrap_or(DEFAULT_LIMIT as u64) as usize; + + if limit == 0 { + return Err("list_dir limit must be 1 or greater".to_string()); + } + + let mut results = Vec::new(); + let mut truncated = false; + if recursive { + list_recursive( + &directory_path, + 0, + max_depth, + include_hidden, + limit, + &mut results, + &mut truncated, + )?; + } else { + list_single( + &directory_path, + include_hidden, + &mut results, + limit, + &mut truncated, + )?; + } + + if results.is_empty() { + return Ok(format!("Directory is empty: {}", directory_path.display())); + } + + if truncated { + results.push(format!("[list_dir truncated at {} entries]", limit)); + } + + Ok(results.join("\n")) +} + +fn resolve_directory(ctx: &DesktopToolContext<'_>, path: Option<&str>) -> Result { + let resolved = match path { + Some(value) => ctx.resolve_scoped_path(value, false)?, + None => ctx.working_directory().to_path_buf(), + }; + + if !resolved.is_dir() { + return Err(format!("Path is not a directory: {}", resolved.display())); + } + + Ok(resolved) +} + +fn list_single( + directory_path: &Path, + include_hidden: bool, + results: &mut Vec, + limit: usize, + truncated: &mut bool, +) -> Result<(), String> { + let mut entries = collect_entries(directory_path, include_hidden)?; + sort_entries(&mut entries); + + for entry in entries { + if results.len() >= limit { + *truncated = true; + break; + } + results.push(render_entry(&entry.path, 0, entry.is_dir, entry.size)); + } + + Ok(()) +} + +fn list_recursive( + directory_path: &Path, + depth: usize, + max_depth: usize, + include_hidden: bool, + limit: usize, + results: &mut Vec, + truncated: &mut bool, +) -> Result<(), String> { + if depth > max_depth || results.len() >= limit { + if results.len() >= limit { + *truncated = true; + } + return Ok(()); + } + + let mut entries = collect_entries(directory_path, include_hidden)?; + sort_entries(&mut entries); + + for entry in entries { + if results.len() >= limit { + *truncated = true; + break; + } + + results.push(render_entry(&entry.path, depth, entry.is_dir, entry.size)); + if entry.is_dir { + list_recursive( + &entry.path, + depth + 1, + max_depth, + include_hidden, + limit, + results, + truncated, + )?; + } + } + + Ok(()) +} + +#[derive(Clone)] +struct DirectoryEntry { + path: PathBuf, + is_dir: bool, + size: u64, +} + +fn collect_entries( + directory_path: &Path, + include_hidden: bool, +) -> Result, String> { + let entries = fs::read_dir(directory_path).map_err(|error| { + format!( + "Failed to read directory {}: {}", + directory_path.display(), + error + ) + })?; + let mut collected = Vec::new(); + + for entry_result in entries { + let entry = + entry_result.map_err(|error| format!("Failed to read directory entry: {}", error))?; + let name = entry.file_name().to_string_lossy().to_string(); + if should_skip_name(&name, include_hidden) { + continue; + } + + let metadata = entry.metadata().map_err(|error| { + format!( + "Failed to read metadata for {}: {}", + entry.path().display(), + error + ) + })?; + collected.push(DirectoryEntry { + path: entry.path(), + is_dir: metadata.is_dir(), + size: metadata.len(), + }); + } + + Ok(collected) +} + +fn sort_entries(entries: &mut [DirectoryEntry]) { + entries.sort_by(|left, right| match (left.is_dir, right.is_dir) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => left.path.cmp(&right.path), + }); +} + +fn should_skip_name(name: &str, include_hidden: bool) -> bool { + if include_hidden { + return false; + } + + name.starts_with('.') || COMMON_SKIP_DIRS.iter().any(|value| value == &name) +} + +fn render_entry(path: &Path, depth: usize, is_dir: bool, size: u64) -> String { + let indent = " ".repeat(depth); + let label = path + .file_name() + .map(|value| value.to_string_lossy().to_string()) + .unwrap_or_else(|| path.display().to_string()); + if is_dir { + format!("{indent}[dir] {label}/") + } else { + format!("{indent}[file] {label} ({})", format_size(size)) + } +} + +fn format_size(size: u64) -> String { + if size < 1024 { + format!("{size} B") + } else if size < 1024 * 1024 { + format!("{:.1} KB", size as f64 / 1024.0) + } else if size < 1024 * 1024 * 1024 { + format!("{:.1} MB", size as f64 / (1024.0 * 1024.0)) + } else { + format!("{:.1} GB", size as f64 / (1024.0 * 1024.0 * 1024.0)) + } +} diff --git a/frontend/src-tauri/src/cowork/desktop_tools/mod.rs b/frontend/src-tauri/src/cowork/desktop_tools/mod.rs new file mode 100644 index 000000000..0e6126c47 --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_tools/mod.rs @@ -0,0 +1,394 @@ +mod apply_patch; +mod bash; +mod edit; +mod glob; +mod grep; +mod list_dir; +mod read; +mod todo_write; +mod wasm_run; +mod write; + +use crate::cowork::agent_presets::shared::DesktopToolCapability; +use crate::cowork::desktop_runtime::wasm::{WasmRunError, WasmRunResult}; +use serde_json::Value; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use tauri::AppHandle; + +pub const TOOL_BASH: &str = "Bash"; +pub const TOOL_READ: &str = "Read"; +pub const TOOL_WRITE: &str = "Write"; +pub const TOOL_EDIT: &str = "Edit"; +pub const TOOL_APPLY_PATCH: &str = "apply_patch"; +pub const TOOL_TODO_WRITE: &str = "TodoWrite"; +pub const TOOL_GLOB: &str = "glob"; +pub const TOOL_GREP: &str = "grep"; +pub const TOOL_LIST_DIR: &str = "list_dir"; +pub const TOOL_WASM_RUN: &str = "wasm_run"; + +pub type DesktopToolExecuteFn = + for<'a> fn(&mut DesktopToolContext<'a>, &Value) -> Result; +pub type DesktopExecutionScopeLoaderFn = + fn(&AppHandle, &str) -> Result; +pub type DesktopExecutionScopeRefreshFn = + fn(&AppHandle, &str, &DesktopExecutionScope) -> Result<(), String>; + +#[derive(Clone)] +pub struct DesktopTool { + capability: DesktopToolCapability, + execute: DesktopToolExecuteFn, + refreshes_scope: bool, +} + +impl DesktopTool { + pub fn new( + capability: DesktopToolCapability, + execute: DesktopToolExecuteFn, + refreshes_scope: bool, + ) -> Self { + Self { + capability, + execute, + refreshes_scope, + } + } + + pub fn name(&self) -> &str { + self.capability.name.as_str() + } + + pub fn display_name(&self) -> &str { + self.capability.display_name.as_str() + } + + pub fn aliases(&self) -> &[String] { + self.capability.aliases.as_slice() + } + + pub fn into_capability(self) -> DesktopToolCapability { + self.capability + } + + pub fn refreshes_scope(&self) -> bool { + self.refreshes_scope + } + + pub fn execute( + &self, + ctx: &mut DesktopToolContext<'_>, + input: &Value, + ) -> Result { + (self.execute)(ctx, input) + } + + pub fn with_aliases(mut self, aliases: &[&str]) -> Self { + self.capability.aliases = aliases.iter().map(|value| value.to_string()).collect(); + self + } +} + +#[derive(Default)] +pub struct DesktopToolRuntime { + bash_sessions: HashMap, + todos: Option, +} + +#[derive(Clone, Default)] +pub(crate) struct DesktopBashSession { + pub(crate) last_output: String, + pub(crate) last_exit_code: Option, +} + +pub struct DesktopExecutionScope { + canonical_root: PathBuf, +} + +pub struct DesktopToolContext<'a> { + app: &'a AppHandle, + local_session_id: &'a str, + execution_scope: &'a DesktopExecutionScope, + runtime: &'a mut DesktopToolRuntime, + refresh_scope: Option, +} + +impl<'a> DesktopToolContext<'a> { + pub fn new( + app: &'a AppHandle, + local_session_id: &'a str, + execution_scope: &'a DesktopExecutionScope, + runtime: &'a mut DesktopToolRuntime, + refresh_scope: Option, + ) -> Self { + Self { + app, + local_session_id, + execution_scope, + runtime, + refresh_scope, + } + } + + pub fn working_directory(&self) -> &Path { + &self.execution_scope.canonical_root + } + + pub fn local_session_id(&self) -> &str { + self.local_session_id + } + + pub fn app_handle(&self) -> &AppHandle { + self.app + } + + pub fn resolve_scoped_path( + &self, + file_path: &str, + allow_missing: bool, + ) -> Result { + resolve_scoped_path(self.execution_scope, file_path, allow_missing) + } + + pub fn refresh_scope_snapshot(&self) -> Result<(), String> { + match self.refresh_scope { + Some(refresh_scope) => { + refresh_scope(self.app, self.local_session_id, self.execution_scope) + } + None => Ok(()), + } + } + + pub(crate) fn bash_sessions(&self) -> &HashMap { + &self.runtime.bash_sessions + } + + pub(crate) fn bash_sessions_mut(&mut self) -> &mut HashMap { + &mut self.runtime.bash_sessions + } + + pub(crate) fn set_todos(&mut self, todos: Value) { + self.runtime.todos = Some(todos); + } +} + +impl DesktopExecutionScope { + pub fn new(canonical_root: PathBuf) -> Self { + Self { canonical_root } + } +} + +pub fn common_desktop_tools() -> Vec { + vec![ + bash::desktop_tool(), + list_dir::desktop_tool(), + glob::desktop_tool(), + grep::desktop_tool(), + read::desktop_tool(), + write::desktop_tool(), + edit::desktop_tool(), + apply_patch::desktop_tool(), + todo_write::desktop_tool(), + // desktop_skill_run lives under desktop_skills/ because its job is + // to surface skill guidance, not to drive the host filesystem. + // It still appears in the common tool list so every cowork mode + // that ships desktop tools automatically advertises it. + crate::cowork::desktop_skills::desktop_skill_run::desktop_tool(), + wasm_run::desktop_tool(), + ] +} + +pub fn common_desktop_tool_capabilities() -> Vec { + common_desktop_tools() + .into_iter() + .map(DesktopTool::into_capability) + .collect() +} + +pub fn common_desktop_tool_names() -> Vec { + common_desktop_tool_capabilities() + .into_iter() + .map(|tool| tool.name) + .collect() +} + +pub fn find_desktop_tool<'a>(tools: &'a [DesktopTool], tool_name: &str) -> Option<&'a DesktopTool> { + let normalized = tool_name.trim().to_lowercase(); + tools.iter().find(|tool| { + tool.name().eq_ignore_ascii_case(&normalized) + || tool + .aliases() + .iter() + .any(|alias| alias.eq_ignore_ascii_case(&normalized)) + }) +} + +pub fn resolve_desktop_tool_name(tool_name: &str) -> Option { + let normalized = tool_name.trim().to_lowercase(); + if normalized.is_empty() { + return None; + } + + common_desktop_tools().into_iter().find_map(|tool| { + if tool.name().eq_ignore_ascii_case(&normalized) + || tool + .aliases() + .iter() + .any(|alias| alias.eq_ignore_ascii_case(&normalized)) + { + Some(tool.name().to_string()) + } else { + None + } + }) +} + +/// Shared formatter for desktop tools that invoke the WASM runtime. +/// +/// This stays in the desktop tool layer because it renders the +/// user-facing tool result string, not the runtime's execution contract. +pub(super) fn format_wasm_result(result: &WasmRunResult) -> String { + let mut sections = Vec::new(); + sections.push(format!( + "module: {}\nduration_ms: {}", + result.module, result.duration_ms + )); + if !result.stdout.trim().is_empty() { + sections.push(format!("stdout:\n{}", result.stdout.trim_end())); + } + if !result.stderr.trim().is_empty() { + sections.push(format!("stderr:\n{}", result.stderr.trim_end())); + } + if let Some(output_json) = &result.output_json { + if let Ok(pretty) = serde_json::to_string_pretty(output_json) { + sections.push(format!("output.json:\n{}", pretty)); + } + } + if !result.output_files.is_empty() { + let listing = result + .output_files + .iter() + .map(|file| format!("- {} ({} bytes)", file.name, file.bytes)) + .collect::>() + .join("\n"); + sections.push(format!("output_files:\n{}", listing)); + } + if let Some(scratch) = &result.scratch_dir { + sections.push(format!("scratch_dir: {}", scratch.display())); + } + sections.join("\n\n") +} + +/// Shared formatter for human-readable WASM tool errors. +pub(super) fn format_wasm_error(tool: &str, module: &str, error: WasmRunError) -> String { + let (message, hint) = match &error { + WasmRunError::UnknownModule(name) => ( + format!("{tool}: unknown module '{name}'"), + "Check that the skill body references a registered module name.".to_string(), + ), + WasmRunError::Timeout(duration) => ( + format!("{tool}: module '{module}' timed out after {duration:?}"), + "The file may be too complex. Try a smaller file or a subset (e.g., specific page range).".to_string(), + ), + WasmRunError::OutOfFuel => ( + format!("{tool}: module '{module}' exhausted its instruction budget"), + "The file requires more processing than the budget allows. Try a smaller file.".to_string(), + ), + WasmRunError::MemoryLimit(limit) => ( + format!("{tool}: module '{module}' exceeded memory limit {limit} bytes"), + "The file is too large for the current memory budget. For PDF, the host auto-splits large files. For docx/xlsx/pptx, try a smaller file or extract a subset.".to_string(), + ), + WasmRunError::Io(detail) => ( + format!("{tool}: I/O error preparing sandbox: {detail}"), + "Check that the input file exists and is readable.".to_string(), + ), + WasmRunError::ModuleLoad(detail) => ( + format!("{tool}: failed to load module: {detail}"), + "The WebAssembly module may be corrupt. This is an internal error.".to_string(), + ), + WasmRunError::Execution(detail) => ( + format!("{tool}: module '{module}' failed during execution: {detail}"), + "The file may be corrupt or in an unsupported format. Do not retry with the same file.".to_string(), + ), + }; + format!("{message}\n\nHint: {hint}") +} + +fn resolve_scoped_path( + execution_scope: &DesktopExecutionScope, + file_path: &str, + allow_missing: bool, +) -> Result { + let raw_path = PathBuf::from(file_path); + let scoped_path = if raw_path.is_absolute() { + raw_path + } else { + execution_scope.canonical_root.join(raw_path) + }; + + if scoped_path.exists() { + let canonical = fs::canonicalize(&scoped_path) + .map_err(|error| format!("Failed to resolve {}: {}", scoped_path.display(), error))?; + ensure_within_scope(execution_scope, &canonical)?; + return Ok(canonical); + } + + if !allow_missing { + return Err(format!("Path does not exist: {}", scoped_path.display())); + } + + let (existing_ancestor, relative_suffix) = split_existing_ancestor(&scoped_path)?; + if relative_suffix + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(format!( + "Desktop tool path cannot escape desktop scope: {}", + scoped_path.display() + )); + } + let canonical_ancestor = fs::canonicalize(&existing_ancestor).map_err(|error| { + format!( + "Failed to resolve ancestor {}: {}", + existing_ancestor.display(), + error + ) + })?; + ensure_within_scope(execution_scope, &canonical_ancestor)?; + Ok(canonical_ancestor.join(relative_suffix)) +} + +fn split_existing_ancestor(path: &Path) -> Result<(PathBuf, PathBuf), String> { + let mut current = path.to_path_buf(); + let mut suffix = PathBuf::new(); + + while !current.exists() { + let file_name = current + .file_name() + .map(PathBuf::from) + .ok_or_else(|| format!("Unable to resolve path ancestor for {}", path.display()))?; + suffix = if suffix.as_os_str().is_empty() { + file_name + } else { + PathBuf::from(file_name).join(suffix) + }; + current = current + .parent() + .map(PathBuf::from) + .ok_or_else(|| format!("Unable to resolve path ancestor for {}", path.display()))?; + } + + Ok((current, suffix)) +} + +fn ensure_within_scope(execution_scope: &DesktopExecutionScope, path: &Path) -> Result<(), String> { + if path.starts_with(&execution_scope.canonical_root) { + Ok(()) + } else { + Err(format!( + "Desktop tool path {} is outside desktop scope {}", + path.display(), + execution_scope.canonical_root.display() + )) + } +} diff --git a/frontend/src-tauri/src/cowork/desktop_tools/read.rs b/frontend/src-tauri/src/cowork/desktop_tools/read.rs new file mode 100644 index 000000000..4ee0db327 --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_tools/read.rs @@ -0,0 +1,79 @@ +use super::{DesktopTool, DesktopToolContext, TOOL_READ}; +use crate::cowork::agent_presets::shared::DesktopToolCapability; +use serde_json::{json, Value}; + +const DEFAULT_READ_LIMIT: usize = 2000; + +pub fn desktop_tool() -> DesktopTool { + DesktopTool::new( + DesktopToolCapability { + name: TOOL_READ.to_string(), + aliases: Vec::new(), + display_name: "Read local file".to_string(), + description: "Read a text file from the selected local desktop folder. The path may be absolute or relative to the current desktop scope.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "The file path to read. It may be absolute or relative to the current desktop scope." + }, + "path": { + "type": "string", + "description": "Alias for file_path. May be absolute or relative to the current desktop scope." + }, + "limit": { + "type": "integer", + "description": "Optional number of lines to read. Defaults to 2000." + }, + "offset": { + "type": "integer", + "description": "Optional 1-based line offset to start reading from." + } + }, + "required": [] + }), + }, + execute, + false, + ) + .with_aliases(&["read_file"]) +} + +fn execute(ctx: &mut DesktopToolContext<'_>, tool_input: &Value) -> Result { + let file_path = tool_input + .get("file_path") + .or_else(|| tool_input.get("path")) + .and_then(Value::as_str) + .ok_or_else(|| "Read requires file_path or path".to_string())?; + let resolved_path = ctx.resolve_scoped_path(file_path, false)?; + let contents = std::fs::read_to_string(&resolved_path) + .map_err(|error| format!("Failed to read {}: {}", resolved_path.display(), error))?; + let offset = tool_input + .get("offset") + .and_then(Value::as_u64) + .unwrap_or(1) as usize; + let limit = tool_input + .get("limit") + .and_then(Value::as_u64) + .unwrap_or(DEFAULT_READ_LIMIT as u64) as usize; + if offset == 0 { + return Err("Read offset must be 1 or greater".to_string()); + } + if limit == 0 { + return Err("Read limit must be 1 or greater".to_string()); + } + + let lines = contents.lines().collect::>(); + let start = offset.saturating_sub(1); + if start >= lines.len() { + return Ok(String::new()); + } + let end = start.saturating_add(limit).min(lines.len()); + let mut rendered = Vec::new(); + for (index, line) in lines[start..end].iter().enumerate() { + rendered.push(format!("{}\t{}", start + index + 1, line)); + } + + Ok(rendered.join("\n")) +} diff --git a/frontend/src-tauri/src/cowork/desktop_tools/todo_write.rs b/frontend/src-tauri/src/cowork/desktop_tools/todo_write.rs new file mode 100644 index 000000000..850cbaf7b --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_tools/todo_write.rs @@ -0,0 +1,57 @@ +use super::{DesktopTool, DesktopToolContext, TOOL_TODO_WRITE}; +use crate::cowork::agent_presets::shared::DesktopToolCapability; +use serde_json::{json, Value}; + +pub fn desktop_tool() -> DesktopTool { + DesktopTool::new( + DesktopToolCapability { + name: TOOL_TODO_WRITE.to_string(), + aliases: Vec::new(), + display_name: "Write desktop todo list".to_string(), + description: + "Store and update a structured todo list for the current desktop cowork run." + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "todos": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "content": {"type": "string"}, + "status": { + "type": "string", + "enum": ["pending", "in_progress", "completed"] + }, + "priority": { + "type": "string", + "enum": ["low", "medium", "high"] + } + }, + "required": ["id", "content", "status", "priority"] + }, + "description": "The updated todo list for the current run." + } + }, + "required": ["todos"] + }), + }, + execute, + false, + ) +} + +fn execute(ctx: &mut DesktopToolContext<'_>, tool_input: &Value) -> Result { + let todos = tool_input + .get("todos") + .cloned() + .ok_or_else(|| "TodoWrite requires todos".to_string())?; + let todo_count = todos.as_array().map(Vec::len).unwrap_or(0); + ctx.set_todos(todos); + Ok(format!( + "Stored {} desktop todo items for this cowork run", + todo_count + )) +} diff --git a/frontend/src-tauri/src/cowork/desktop_tools/wasm_run.rs b/frontend/src-tauri/src/cowork/desktop_tools/wasm_run.rs new file mode 100644 index 000000000..ee1a53273 --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_tools/wasm_run.rs @@ -0,0 +1,740 @@ +//! `wasm_run` desktop tool. +//! +//! This tool exposes the isolated desktop WASM runtime to the agent. It is +//! deliberately scoped as the **isolation path** for processing tasks — +//! not as a replacement for normal file tools. The agent should prefer +//! `Read`, `Write`, `Edit`, `Bash`, etc. for everyday local work and only +//! reach for `wasm_run` when a skill explicitly asks for isolated +//! processing (for example, extracting text from a PDF with tight memory +//! and timeout limits). +//! +//! ## Chunking +//! +//! Before running the guest, the tool asks +//! [`crate::cowork::desktop_runtime::wasm::chunking::classify_and_plan`] +//! whether the call needs to be split. For small inputs the answer is +//! always `ChunkPlan::Single` and the tool runs a single WASM call. +//! For large inputs where a format-specific planner exists, the plan +//! is [`ChunkPlan::Multi`] and the tool dispatches several sequential +//! calls, merges their structured outputs, and returns a combined +//! result. Chunking is transparent to the LLM: it sees one tool call, +//! one tool result. +//! +//! ## Input contract +//! +//! The tool accepts a module name and an optional JSON payload. Input +//! files selected from the current desktop scope are mirrored into the +//! guest's preopened `/workspace/inputs` directory before the module +//! runs. The guest may write outputs to `/workspace/outputs` and/or a +//! structured `/workspace/output.json`; the tool reports back both. +//! +//! The set of modules the agent can invoke is defined by the desktop +//! runtime's [`ModuleRegistry`](crate::cowork::desktop_runtime::wasm::ModuleRegistry). +//! Unknown module names return an error without touching the filesystem. + +use super::{DesktopTool, DesktopToolContext, TOOL_WASM_RUN}; +use crate::cowork::agent_presets::shared::DesktopToolCapability; +use crate::cowork::desktop_runtime::wasm::chunking::{ + self, splitter, Chunk, ChunkPlan, MergeStrategy, +}; +use crate::cowork::desktop_runtime::wasm::{ + acquire_runtime, WasmEntrypoint, WasmRunRequest, WasmRunResult, +}; +use crate::cowork::desktop_runtime::RuntimeLimits; +use serde_json::Value; +use std::path::PathBuf; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Emitter}; + +pub fn desktop_tool() -> DesktopTool { + DesktopTool::new( + DesktopToolCapability { + name: TOOL_WASM_RUN.to_string(), + aliases: Vec::new(), + display_name: "Run an isolated WASM processing module".to_string(), + description: "Invoke a desktop-owned WebAssembly module inside an isolated sandbox with memory, fuel, and wall-clock limits. Use this when a skill explicitly requires isolated processing (for example, parsing a user document into structured data). Do NOT use it for normal file reading, editing, or shell work — use the Read, Write, Edit, and Bash tools for those. The module must be registered in the desktop WASM runtime. Optional input_files are copied into the guest's /workspace/inputs directory. Optional input_json is written to /workspace/input.json. The guest may return stdout/stderr, a structured /workspace/output.json, and files in /workspace/outputs. Large inputs (for example PDFs over 100 MB) may be transparently split by the host into several sequential sandbox calls whose structured outputs are merged back into one tool result.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "Name of the WASM module to run. The module must be pre-registered in the desktop runtime." + }, + "entrypoint": { + "type": "string", + "description": "Optional exported function name to call instead of the default `_start`. Leave unset for standard WASI command modules." + }, + "input_json": { + "type": "object", + "description": "Optional JSON payload written to /workspace/input.json before the module runs. Use this to pass structured arguments to the module." + }, + "input_files": { + "type": "array", + "description": "Optional list of host files (relative or absolute within the desktop scope) to mirror into /workspace/inputs inside the sandbox.", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Source path, absolute or relative to the current desktop scope." + }, + "name": { + "type": "string", + "description": "Optional logical filename to expose inside /workspace/inputs. Defaults to the source filename." + } + }, + "required": ["path"] + } + }, + "keep_workspace": { + "type": "boolean", + "description": "Keep the scratch workspace on disk after the call for debugging. Defaults to false." + }, + "timeout_seconds": { + "type": "integer", + "description": "Optional wall-clock timeout in seconds. Defaults to the runtime default. Applied per chunk when the host splits the call." + } + }, + "required": ["module"] + }), + }, + execute, + false, + ) +} + +/// Maximum total wall-clock time a multi-chunk run may spend before +/// aborting mid-sequence. Prevents pathological PDF causing indefinite +/// blocking. +const MAX_MULTI_CHUNK_TOTAL_TIMEOUT: Duration = Duration::from_secs(600); // 10 minutes + +/// Holds everything the tool parsed from the LLM's input block, so the +/// single-call and multi-call dispatch paths can share code. +struct ParsedInput { + module_name: String, + /// Local cowork session id captured at parse time. Used as the + /// scratch-dir scope for every chunk in a multi-chunk plan so the + /// runtime's session sweeper can reclaim them together. + session_id: String, + base_input_json: Option, + input_files: Vec<(PathBuf, String)>, + entrypoint: WasmEntrypoint, + keep_workspace: bool, + /// When set by the user, this is the **total** timeout for the entire + /// call. For multi-chunk plans, each chunk gets `total / chunk_count` + /// as its per-chunk wall deadline (never less than 10 s). + user_timeout_override: Option, + /// Tauri app handle for emitting progress events during multi-chunk + /// dispatch. If absent (unit test context), progress events are skipped. + app_handle: Option, +} + +fn execute(ctx: &mut DesktopToolContext<'_>, tool_input: &Value) -> Result { + let parsed = parse_input(ctx, tool_input)?; + let module_name = parsed.module_name.clone(); + + // Emit start event so the desktop UI can show a spinner. + emit_lifecycle_event(&parsed, "wasm_run:started"); + + // Lease the shared desktop WASM runtime. This lazily creates the + // wasmtime engine on first use and keeps it alive for the duration + // of the lease so the idle reaper cannot tear it down mid-call. + let lease = acquire_runtime() + .map_err(|error| format!("wasm_run: failed to acquire runtime: {error}"))?; + if !lease.registry().has(&module_name) { + let available = lease.registry().names().join(", "); + return Err(format!( + "wasm_run: module '{}' is not registered. Available modules: [{}]", + module_name, available + )); + } + + // Classify the call. This is the only place that knows about + // chunking policy — the rest of the dispatch path treats plan + // variants uniformly. + let op_name = parsed + .base_input_json + .as_ref() + .and_then(|value| value.get("op")) + .and_then(Value::as_str); + let primary_file = parsed.input_files.first().map(|(path, _)| path.as_path()); + let plan = chunking::classify_and_plan( + &module_name, + primary_file, + op_name, + parsed.base_input_json.as_ref().unwrap_or(&Value::Null), + )?; + + // Fix #21: multi-file + chunkable plan is ambiguous — which file do + // we chunk? Error early so the LLM knows to split its call. + if plan.is_multi() && parsed.input_files.len() > 1 { + return Err("wasm_run: cannot chunk a call with multiple input_files. \ + Split into one call per file so each can be chunked independently." + .to_string()); + } + + let outcome = match plan { + ChunkPlan::Single { limits } => run_single(&lease, &parsed, limits, None), + ChunkPlan::Multi { + chunks, + merge, + use_host_split, + } => { + if use_host_split { + run_multi_with_host_split(&lease, &parsed, chunks, merge) + } else { + run_multi(&lease, &parsed, chunks, merge) + } + } + }; + + drop(lease); + emit_lifecycle_event(&parsed, "wasm_run:completed"); + outcome +} + +fn parse_input( + ctx: &mut DesktopToolContext<'_>, + tool_input: &Value, +) -> Result { + let module_name = tool_input + .get("module") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "wasm_run requires a 'module' name".to_string())? + .to_string(); + + let base_input_json = tool_input + .get("input_json") + .filter(|v| !v.is_null()) + .cloned(); + + let entrypoint = tool_input + .get("entrypoint") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| WasmEntrypoint::NamedVoid(value.to_string())) + .unwrap_or(WasmEntrypoint::Start); + + let keep_workspace = tool_input + .get("keep_workspace") + .and_then(Value::as_bool) + .unwrap_or(false); + + let user_timeout_override = tool_input + .get("timeout_seconds") + .and_then(Value::as_u64) + .map(|secs| std::time::Duration::from_secs(secs.min(120))); + + let mut input_files = Vec::new(); + if let Some(entries) = tool_input.get("input_files").and_then(Value::as_array) { + for entry in entries { + let Some(spec) = entry.as_object() else { + return Err("wasm_run: input_files entries must be objects".to_string()); + }; + let raw_path = spec + .get("path") + .and_then(Value::as_str) + .ok_or_else(|| "wasm_run: input_files entry requires a 'path'".to_string())?; + let resolved = ctx.resolve_scoped_path(raw_path, false)?; + let logical_name = spec + .get("name") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| { + PathBuf::from(raw_path) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "input.bin".to_string()) + }); + input_files.push((resolved, logical_name)); + } + } + + Ok(ParsedInput { + module_name, + session_id: ctx.local_session_id().to_string(), + base_input_json, + input_files, + entrypoint, + keep_workspace, + user_timeout_override, + app_handle: Some(ctx.app_handle().clone()), + }) +} + +fn build_request( + parsed: &ParsedInput, + session_id: &str, + limits: RuntimeLimits, + input_json_overlay: Option<&Value>, +) -> WasmRunRequest { + let input_json = build_effective_input_json(parsed, input_json_overlay); + + let mut request = + WasmRunRequest::new(parsed.module_name.clone()).with_session_id(session_id.to_string()); + request.input_json = input_json; + request.entrypoint = parsed.entrypoint.clone(); + request.keep_workspace = parsed.keep_workspace; + request.input_files = parsed.input_files.clone(); + + let mut effective_limits = limits; + if let Some(user_timeout) = parsed.user_timeout_override { + effective_limits.wall_timeout = user_timeout; + } + request.limits = Some(effective_limits); + request +} + +fn build_effective_input_json( + parsed: &ParsedInput, + input_json_overlay: Option<&Value>, +) -> Option { + match (parsed.base_input_json.as_ref(), input_json_overlay) { + (None, None) => None, + (Some(base), None) => Some(base.clone()), + (None, Some(overlay)) => Some(overlay.clone()), + (Some(base), Some(overlay)) => Some(merge_json_objects(base.clone(), overlay)), + } +} + +/// Shallow merge two JSON values, preferring keys from `overlay`. Both +/// must be objects; if either is not, `overlay` wins outright. +fn merge_json_objects(base: Value, overlay: &Value) -> Value { + let (Value::Object(mut base_map), Value::Object(overlay_map)) = (base, overlay) else { + return overlay.clone(); + }; + for (key, value) in overlay_map.iter() { + base_map.insert(key.clone(), value.clone()); + } + Value::Object(base_map) +} + +fn run_single( + lease: &crate::cowork::desktop_runtime::wasm::lifecycle::RuntimeLease, + parsed: &ParsedInput, + limits: RuntimeLimits, + overlay: Option<&Value>, +) -> Result { + if parsed.module_name == "pdf_processor" { + let (input_file, _logical_name) = parsed + .input_files + .first() + .ok_or_else(|| "wasm_run: pdf_processor requires one input file".to_string())?; + let result = splitter::run_pdf_processor_in_helper( + input_file, + build_effective_input_json(parsed, overlay), + )?; + return Ok(super::format_wasm_result(&result)); + } + + let request = build_request(parsed, &parsed.session_id, limits, overlay); + let result = lease.run(request); + match result { + Ok(result) => Ok(super::format_wasm_result(&result)), + Err(error) => Err(super::format_wasm_error( + "wasm_run", + &parsed.module_name, + error, + )), + } +} + +fn run_multi( + lease: &crate::cowork::desktop_runtime::wasm::lifecycle::RuntimeLease, + parsed: &ParsedInput, + mut chunks: Vec, + merge: MergeStrategy, +) -> Result { + if chunks.is_empty() { + return Err("wasm_run: chunking planner returned an empty chunk list".to_string()); + } + let chunk_count = chunks.len(); + + // Fix #14 + #23: compute per-chunk timeout from user total override. + // If user set `timeout_seconds`, that's the TOTAL budget across all + // chunks. Each chunk gets total/chunks (min 10 s per chunk). If no + // user override, use planner-assigned limits as-is but enforce + // MAX_MULTI_CHUNK_TOTAL_TIMEOUT as a hard ceiling. + let total_deadline = parsed + .user_timeout_override + .unwrap_or(MAX_MULTI_CHUNK_TOTAL_TIMEOUT); + let per_chunk_timeout = + Duration::from_secs((total_deadline.as_secs() / chunk_count as u64).max(10)); + + // Override per-chunk limits with the computed timeout. + for chunk in &mut chunks { + chunk.limits.wall_timeout = per_chunk_timeout; + } + + let global_start = Instant::now(); + let mut chunk_outputs: Vec = Vec::with_capacity(chunk_count); + let mut combined_stdout = String::new(); + let mut combined_stderr = String::new(); + let mut total_duration_ms: u128 = 0; + let mut chunk_summaries: Vec = Vec::with_capacity(chunk_count); + + for (i, chunk) in chunks.iter().enumerate() { + // Fix #14: check global timeout before starting next chunk. + if global_start.elapsed() > total_deadline { + return Err(format!( + "wasm_run: multi-chunk run exceeded total timeout ({} s) after completing {}/{} chunks", + total_deadline.as_secs(), + i, + chunk_count + )); + } + + let request = build_request( + parsed, + &parsed.session_id, + chunk.limits, + Some(&chunk.input_json_overlay), + ); + let label = chunk.label.clone(); + let result: Result = lease.run(request); + match result { + Ok(chunk_result) => { + total_duration_ms += chunk_result.duration_ms; + if !chunk_result.stdout.trim().is_empty() { + combined_stdout.push_str(&format!( + "[{}]\n{}\n", + label, + chunk_result.stdout.trim_end() + )); + } + if !chunk_result.stderr.trim().is_empty() { + combined_stderr.push_str(&format!( + "[{}]\n{}\n", + label, + chunk_result.stderr.trim_end() + )); + } + let Some(output_json) = chunk_result.output_json else { + return Err(format!( + "wasm_run: chunk {label} produced no output.json; cannot merge" + )); + }; + chunk_summaries.push(format!( + "- [{label}] duration_ms={}, items_in_chunk={}", + chunk_result.duration_ms, + first_array_len(&output_json) + )); + chunk_outputs.push(output_json); + + // Fix #19: emit progress event so the UI can show a + // progress bar between chunks. + emit_chunk_progress(parsed, i + 1, chunk_count, &label); + } + Err(error) => { + let error_msg = super::format_wasm_error("wasm_run", &parsed.module_name, error); + // Return partial results if we have any, instead of + // losing all completed work. + return format_multi_result( + parsed, + &global_start, + total_duration_ms, + chunk_count, + &chunk_outputs, + &chunk_summaries, + &combined_stdout, + &combined_stderr, + merge, + Some(&error_msg), + ); + } + } + } + + format_multi_result( + parsed, + &global_start, + total_duration_ms, + chunk_count, + &chunk_outputs, + &chunk_summaries, + &combined_stdout, + &combined_stderr, + merge, + None, + ) +} + +/// Format the result of a multi-chunk run, optionally with a partial-failure error. +fn format_multi_result( + parsed: &ParsedInput, + global_start: &Instant, + total_duration_ms: u128, + chunk_count: usize, + chunk_outputs: &[Value], + chunk_summaries: &[String], + combined_stdout: &str, + combined_stderr: &str, + merge: MergeStrategy, + error: Option<&str>, +) -> Result { + let wall_ms = global_start.elapsed().as_millis(); + let completed = chunk_outputs.len(); + let status = if error.is_some() { + format!("PARTIAL ({completed}/{chunk_count} chunks succeeded)") + } else { + format!("{chunk_count} (sequential)") + }; + + let mut sections: Vec = Vec::new(); + sections.push(format!( + "module: {}\nwall_time_ms: {wall_ms}\ncpu_time_ms: {total_duration_ms}\nchunks: {status}", + parsed.module_name + )); + + if !chunk_summaries.is_empty() { + sections.push(format!("chunk_summary:\n{}", chunk_summaries.join("\n"))); + } + if !combined_stdout.trim().is_empty() { + sections.push(format!("stdout (merged):\n{}", combined_stdout.trim_end())); + } + if !combined_stderr.trim().is_empty() { + sections.push(format!("stderr (merged):\n{}", combined_stderr.trim_end())); + } + + if !chunk_outputs.is_empty() { + match chunking::merge_chunk_outputs(merge, chunk_outputs) { + Ok(merged) => { + if let Ok(pretty) = serde_json::to_string_pretty(&merged) { + sections.push(format!("output.json (merged):\n{pretty}")); + } + } + Err(merge_err) => { + sections.push(format!("merge_error: {merge_err}")); + } + } + } + + if let Some(err) = error { + sections.push(format!("error:\n{err}")); + } + + // If we have at least some output, return Ok (partial success) + // even if a chunk failed, so the LLM can use what was extracted. + // Only return Err if zero chunks succeeded. + if chunk_outputs.is_empty() && error.is_some() { + Err(error.unwrap().to_string()) + } else { + Ok(sections.join("\n\n")) + } +} + +/// Multi-chunk dispatch with host-side file splitting (Fix #7). +/// +/// Instead of passing the full source file + `page_range` overlay to +/// each chunk, this path uses [`splitter::split_pdf_pages`] to produce +/// N smaller PDFs on the host. Each guest call then receives a small +/// file that fits within its 256–512 MB memory budget without loading +/// the entire original document. +/// +/// The chunk plan's `input_json_overlay` (which would contain +/// `page_range`) is **ignored** here because the guest file already +/// contains only the relevant pages. The guest is called without a +/// `page_range` param — it extracts the entire (small) file. +fn run_multi_with_host_split( + _lease: &crate::cowork::desktop_runtime::wasm::lifecycle::RuntimeLease, + parsed: &ParsedInput, + chunks: Vec, + merge: MergeStrategy, +) -> Result { + if chunks.is_empty() { + return Err("wasm_run: host-split: empty chunk list".to_string()); + } + + // We need the primary input file to split. + let (source_path, _logical_name) = parsed + .input_files + .first() + .ok_or_else(|| "wasm_run: host-split requires at least one input file".to_string())?; + + // Determine pages_per_chunk from the first chunk's overlay. + let pages_per_chunk = chunks + .first() + .and_then(|c| c.input_json_overlay.get("page_range")) + .and_then(|r| r.as_array()) + .and_then(|arr| { + let start = arr.first()?.as_u64()?; + let end = arr.get(1)?.as_u64()?; + Some((end - start + 1) as u32) + }) + .unwrap_or(20); + + // Split the PDF on the host side into chunk files. + let split_dir = std::env::temp_dir().join(format!( + "ii-wasm-split-{}-{}", + parsed.session_id, + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or_default() + )); + let chunk_files = splitter::split_pdf_pages(source_path, &split_dir, pages_per_chunk) + .map_err(|e| format!("wasm_run: host-split failed: {e}"))?; + + if chunk_files.is_empty() { + let _ = std::fs::remove_dir_all(&split_dir); + return Err("wasm_run: host-split produced 0 chunk files (empty PDF?)".to_string()); + } + + // Compute timeout. + let total_deadline = parsed + .user_timeout_override + .unwrap_or(MAX_MULTI_CHUNK_TOTAL_TIMEOUT); + + let global_start = Instant::now(); + let mut chunk_outputs: Vec = Vec::with_capacity(chunk_files.len()); + let mut combined_stdout = String::new(); + let mut combined_stderr = String::new(); + let mut total_duration_ms: u128 = 0; + let mut chunk_summaries: Vec = Vec::new(); + let actual_chunk_count = chunk_files.len(); + + for (i, chunk_file) in chunk_files.iter().enumerate() { + if global_start.elapsed() > total_deadline { + let _ = std::fs::remove_dir_all(&split_dir); + return Err(format!( + "wasm_run: host-split exceeded total timeout ({} s) after {}/{} chunks", + total_deadline.as_secs(), + i, + actual_chunk_count + )); + } + + // Remove page_range because the helper receives a pre-split PDF chunk. + let chunk_input_json = parsed + .base_input_json + .as_ref() + .and_then(|v| v.as_object()) + .map(|obj| { + let mut filtered = obj.clone(); + filtered.remove("page_range"); + Value::Object(filtered) + }) + .or_else(|| parsed.base_input_json.clone()); + + let label = format!("pages {}-{}", chunk_file.page_start, chunk_file.page_end); + let result = splitter::run_pdf_processor_in_helper(&chunk_file.path, chunk_input_json); + + match result { + Ok(chunk_result) => { + total_duration_ms += chunk_result.duration_ms; + if !chunk_result.stdout.trim().is_empty() { + combined_stdout + .push_str(&format!("[{label}]\n{}\n", chunk_result.stdout.trim_end())); + } + if !chunk_result.stderr.trim().is_empty() { + combined_stderr + .push_str(&format!("[{label}]\n{}\n", chunk_result.stderr.trim_end())); + } + let Some(mut output_json) = chunk_result.output_json else { + let _ = std::fs::remove_dir_all(&split_dir); + return Err(format!( + "wasm_run: host-split chunk {label} produced no output.json" + )); + }; + // Patch page_offset to reflect position in the original doc. + if let Some(obj) = output_json.as_object_mut() { + obj.insert( + "page_offset".to_string(), + Value::from(chunk_file.page_start as u64), + ); + obj.insert( + "total_pages".to_string(), + Value::from(chunk_file.total_pages as u64), + ); + } + chunk_summaries.push(format!( + "- [{label}] duration_ms={}, items={}", + chunk_result.duration_ms, + first_array_len(&output_json) + )); + chunk_outputs.push(output_json); + emit_chunk_progress(parsed, i + 1, actual_chunk_count, &label); + } + Err(error) => { + let error_msg = format!( + "wasm_run: module '{}' failed: {}", + parsed.module_name, error + ); + let _ = std::fs::remove_dir_all(&split_dir); + return format_multi_result( + parsed, + &global_start, + total_duration_ms, + actual_chunk_count, + &chunk_outputs, + &chunk_summaries, + &combined_stdout, + &combined_stderr, + merge, + Some(&error_msg), + ); + } + } + } + + let _ = std::fs::remove_dir_all(&split_dir); + format_multi_result( + parsed, + &global_start, + total_duration_ms, + actual_chunk_count, + &chunk_outputs, + &chunk_summaries, + &combined_stdout, + &combined_stderr, + merge, + None, + ) +} + +/// Emit a lifecycle event (started/completed) for UI feedback on single calls. +fn emit_lifecycle_event(parsed: &ParsedInput, event_name: &str) { + let Some(app) = &parsed.app_handle else { + return; + }; + let payload = serde_json::json!({ + "module": parsed.module_name, + "session_id": parsed.session_id, + }); + let _ = app.emit(event_name, payload); +} + +/// Emit a progress event after each chunk completes so the desktop +/// UI can render a progress indicator. No-op if app handle is absent +/// (unit tests) or if emit fails (best-effort). +fn emit_chunk_progress(parsed: &ParsedInput, done: usize, total: usize, label: &str) { + let Some(app) = &parsed.app_handle else { + return; + }; + let payload = serde_json::json!({ + "module": parsed.module_name, + "session_id": parsed.session_id, + "chunk_done": done, + "chunk_total": total, + "chunk_label": label, + }); + let _ = app.emit("wasm_run:chunk_progress", payload); +} + +/// Get the length of the first array-valued field in a JSON object. +/// Used for chunk summary (pages, paragraphs, rows, slides — we don't +/// need to know which field it is, just how many items came back). +fn first_array_len(value: &Value) -> usize { + let Some(obj) = value.as_object() else { + return 0; + }; + for val in obj.values() { + if let Some(arr) = val.as_array() { + return arr.len(); + } + } + 0 +} diff --git a/frontend/src-tauri/src/cowork/desktop_tools/write.rs b/frontend/src-tauri/src/cowork/desktop_tools/write.rs new file mode 100644 index 000000000..7cb8bca76 --- /dev/null +++ b/frontend/src-tauri/src/cowork/desktop_tools/write.rs @@ -0,0 +1,61 @@ +use super::{DesktopTool, DesktopToolContext, TOOL_WRITE}; +use crate::cowork::agent_presets::shared::DesktopToolCapability; +use serde_json::{json, Value}; + +pub fn desktop_tool() -> DesktopTool { + DesktopTool::new( + DesktopToolCapability { + name: TOOL_WRITE.to_string(), + aliases: Vec::new(), + display_name: "Write local file".to_string(), + description: "Write or overwrite a local file within the selected desktop folder." + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "The file path to write. It may be absolute or relative to the current desktop scope." + }, + "path": { + "type": "string", + "description": "Alias for file_path. It may be absolute or relative to the current desktop scope." + }, + "content": { + "type": "string", + "description": "The full content to write to the file." + } + }, + "required": ["content"] + }), + }, + execute, + true, + ) + .with_aliases(&["write_file"]) +} + +fn execute(ctx: &mut DesktopToolContext<'_>, tool_input: &Value) -> Result { + let file_path = tool_input + .get("file_path") + .or_else(|| tool_input.get("path")) + .and_then(Value::as_str) + .ok_or_else(|| "Write requires file_path or path".to_string())?; + let content = tool_input + .get("content") + .and_then(Value::as_str) + .ok_or_else(|| "Write requires content".to_string())?; + let resolved_path = ctx.resolve_scoped_path(file_path, true)?; + if let Some(parent) = resolved_path.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + format!( + "Failed to create parent directory {}: {}", + parent.display(), + error + ) + })?; + } + std::fs::write(&resolved_path, content) + .map_err(|error| format!("Failed to write {}: {}", resolved_path.display(), error))?; + Ok(format!("Wrote {}", resolved_path.display())) +} diff --git a/frontend/src-tauri/src/cowork/homepage/chat_sessions.rs b/frontend/src-tauri/src/cowork/homepage/chat_sessions.rs new file mode 100644 index 000000000..440cda676 --- /dev/null +++ b/frontend/src-tauri/src/cowork/homepage/chat_sessions.rs @@ -0,0 +1,311 @@ +use crate::cowork::chat::{ + CoworkChatRunStatus, CoworkChatScope, CoworkChatSessionDetail, CoworkChatSessionSummary, +}; +use chrono::{SecondsFormat, Utc}; +use std::{fs, path::PathBuf}; +use tauri::{AppHandle, Manager}; + +const STORE_DIR_NAME: &str = "cowork"; +const SESSION_STORE_DIR_NAME: &str = "chat-sessions"; + +#[tauri::command] +pub fn list_homepage_chat_sessions( + app: AppHandle, +) -> Result, String> { + let mut sessions = load_sessions(&app)?; + sort_sessions(&mut sessions); + + Ok(sessions.into_iter().map(build_summary).collect()) +} + +#[tauri::command] +pub fn get_homepage_chat_session( + app: AppHandle, + session_id: String, +) -> Result { + load_session(&app, &session_id) +} + +#[tauri::command] +pub fn create_homepage_chat_session( + app: AppHandle, + title: String, +) -> Result { + let normalized_title = title.trim(); + if normalized_title.is_empty() { + return Err("Session title is required".to_string()); + } + + let now = now_iso(); + let session = CoworkChatSessionDetail { + id: generate_session_id(), + scope: CoworkChatScope::Homepage, + title: normalized_title.to_string(), + preview: String::new(), + updated_at: now, + message_count: 0, + runtime_kind: None, + runtime_session_id: None, + messages: Vec::new(), + runtime_events: Vec::new(), + files: Vec::new(), + run_status: CoworkChatRunStatus::Idle, + }; + + write_session(&app, &session)?; + + Ok(session) +} + +#[tauri::command] +pub fn update_homepage_chat_session( + app: AppHandle, + session: CoworkChatSessionDetail, +) -> Result { + validate_session(&session)?; + + let mut normalized_session = CoworkChatSessionDetail { + message_count: session.messages.len(), + ..session + }; + normalized_session.normalize_runtime_binding(); + + write_session(&app, &normalized_session)?; + + Ok(normalized_session) +} + +#[tauri::command] +pub fn rename_homepage_chat_session( + app: AppHandle, + session_id: String, + title: String, +) -> Result { + let normalized_title = title.trim(); + if normalized_title.is_empty() { + return Err("Session title is required".to_string()); + } + + let mut session = load_session(&app, &session_id)?; + session.title = normalized_title.to_string(); + session.updated_at = now_iso(); + + write_session(&app, &session)?; + + Ok(session) +} + +#[tauri::command] +pub fn delete_homepage_chat_session(app: AppHandle, session_id: String) -> Result<(), String> { + delete_session(&app, &session_id) +} + +fn validate_session(session: &CoworkChatSessionDetail) -> Result<(), String> { + if session.scope != CoworkChatScope::Homepage { + return Err("Only homepage sessions can be persisted in chat-sessions".to_string()); + } + + Ok(()) +} + +fn build_summary(session: CoworkChatSessionDetail) -> CoworkChatSessionSummary { + let normalized_session = normalize_session(session); + + CoworkChatSessionSummary { + id: normalized_session.id, + scope: normalized_session.scope, + title: normalized_session.title, + preview: normalized_session.preview, + updated_at: normalized_session.updated_at, + message_count: normalized_session.message_count, + } +} + +fn generate_session_id() -> String { + format!("cowork-homepage-{}", Utc::now().timestamp_millis()) +} + +fn now_iso() -> String { + Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true) +} + +fn sort_sessions(sessions: &mut [CoworkChatSessionDetail]) { + sessions.sort_by(|left, right| { + right + .updated_at + .cmp(&left.updated_at) + .then_with(|| right.id.cmp(&left.id)) + }); +} + +fn load_sessions(app: &AppHandle) -> Result, String> { + let store_dir = session_store_dir_path(app)?; + let entries = fs::read_dir(&store_dir).map_err(|error| { + format!( + "Failed to read homepage chat sessions directory {}: {}", + store_dir.display(), + error + ) + })?; + let mut sessions = Vec::new(); + + for entry in entries { + let entry = entry.map_err(|error| { + format!( + "Failed to read an entry in homepage chat sessions directory {}: {}", + store_dir.display(), + error + ) + })?; + let path = entry.path(); + + if !path.is_file() || path.extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + + sessions.push(normalize_session(read_session_file(&path)?)); + } + + Ok(sessions) +} + +fn load_session(app: &AppHandle, session_id: &str) -> Result { + let session_file = session_file_path(app, session_id)?; + if !session_file.exists() { + return Err(format!( + "Homepage chat session not found: {}", + session_id.trim() + )); + } + + Ok(normalize_session(read_session_file(&session_file)?)) +} + +fn write_session(app: &AppHandle, session: &CoworkChatSessionDetail) -> Result<(), String> { + let session_file = session_file_path(app, &session.id)?; + let contents = serde_json::to_string_pretty(session).map_err(|error| { + format!( + "Failed to serialize homepage chat session {}: {}", + session.id, error + ) + })?; + + fs::write(&session_file, contents).map_err(|error| { + format!( + "Failed to write homepage chat session file {}: {}", + session_file.display(), + error + ) + }) +} + +fn delete_session(app: &AppHandle, session_id: &str) -> Result<(), String> { + let session_file = session_file_path(app, session_id)?; + if !session_file.exists() { + return Err(format!( + "Homepage chat session not found: {}", + session_id.trim() + )); + } + + fs::remove_file(&session_file).map_err(|error| { + format!( + "Failed to delete homepage chat session file {}: {}", + session_file.display(), + error + ) + }) +} + +fn read_session_file(path: &PathBuf) -> Result { + let contents = fs::read_to_string(path).map_err(|error| { + format!( + "Failed to read homepage chat session file {}: {}", + path.display(), + error + ) + })?; + + if contents.trim().is_empty() { + return Err(format!( + "Homepage chat session file is empty: {}", + path.display() + )); + } + + serde_json::from_str(&contents).map_err(|error| { + format!( + "Failed to parse homepage chat session file {}: {}", + path.display(), + error + ) + }) +} + +fn normalize_session(mut session: CoworkChatSessionDetail) -> CoworkChatSessionDetail { + session.normalize_runtime_binding(); + session +} + +fn normalize_session_id(session_id: &str) -> Result { + let trimmed = session_id.trim(); + if trimmed.is_empty() { + return Err("Homepage chat session id is required".to_string()); + } + + if trimmed == "." || trimmed == ".." { + return Err(format!("Invalid homepage chat session id: {}", trimmed)); + } + + if trimmed.chars().any(|character| { + character.is_control() + || matches!( + character, + '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' + ) + }) { + return Err(format!("Invalid homepage chat session id: {}", trimmed)); + } + + Ok(trimmed.to_string()) +} + +fn store_root_dir_path(app: &AppHandle) -> Result { + let mut data_dir = app + .path() + .app_data_dir() + .map_err(|error| format!("Failed to resolve app data directory: {}", error))?; + + data_dir.push(STORE_DIR_NAME); + + fs::create_dir_all(&data_dir).map_err(|error| { + format!( + "Failed to create homepage chat session directory: {}", + error + ) + })?; + + Ok(data_dir) +} + +fn session_store_dir_path(app: &AppHandle) -> Result { + let mut store_root = store_root_dir_path(app)?; + store_root.push(SESSION_STORE_DIR_NAME); + + fs::create_dir_all(&store_root).map_err(|error| { + format!( + "Failed to create homepage chat sessions directory {}: {}", + store_root.display(), + error + ) + })?; + + Ok(store_root) +} + +fn session_file_path(app: &AppHandle, session_id: &str) -> Result { + let normalized_session_id = normalize_session_id(session_id)?; + let mut store_dir = session_store_dir_path(app)?; + store_dir.push(format!("{normalized_session_id}.json")); + Ok(store_dir) +} diff --git a/frontend/src-tauri/src/cowork/homepage/mod.rs b/frontend/src-tauri/src/cowork/homepage/mod.rs new file mode 100644 index 000000000..45b822db7 --- /dev/null +++ b/frontend/src-tauri/src/cowork/homepage/mod.rs @@ -0,0 +1 @@ +pub mod chat_sessions; diff --git a/frontend/src-tauri/src/cowork/intelligent_folder/capabilities/mod.rs b/frontend/src-tauri/src/cowork/intelligent_folder/capabilities/mod.rs new file mode 100644 index 000000000..2a38a2ff7 --- /dev/null +++ b/frontend/src-tauri/src/cowork/intelligent_folder/capabilities/mod.rs @@ -0,0 +1,25 @@ +use crate::cowork::agent_presets::shared::{DesktopSkillCapability, DesktopToolCapability}; +use crate::cowork::desktop_skills::DesktopSkill; +use crate::cowork::desktop_tools::DesktopTool; + +pub fn desktop_tools() -> Vec { + Vec::new() +} + +pub fn desktop_tool_capabilities() -> Vec { + desktop_tools() + .into_iter() + .map(DesktopTool::into_capability) + .collect() +} + +pub fn desktop_runtime_skills() -> Vec { + Vec::new() +} + +pub fn desktop_skill_capabilities() -> Vec { + desktop_runtime_skills() + .into_iter() + .map(DesktopSkill::into_capability) + .collect() +} diff --git a/frontend/src-tauri/src/cowork/intelligent_folder/chat_prompt.rs b/frontend/src-tauri/src/cowork/intelligent_folder/chat_prompt.rs new file mode 100644 index 000000000..6553401e4 --- /dev/null +++ b/frontend/src-tauri/src/cowork/intelligent_folder/chat_prompt.rs @@ -0,0 +1,136 @@ +use crate::cowork::intelligent_folder::sessions::CoworkChatSessionDetail; + +pub fn build_folder_prompt_context(session: &CoworkChatSessionDetail) -> String { + format!( + "You are assisting in II Cowork intelligent-folder mode.\n\ +You are working on the user's real local desktop folder.\n\ +Your job is to inspect, understand, clean up, and refolder files inside that folder based on the user's request.\n\n\ +[Operating rules]\n\ +- Work only inside the selected local folder.\n\ +- Understand the current structure before changing it.\n\ +- Always read relevant files before modifying them when the decision depends on file content, meaning, or purpose.\n\ +- Do not refolder semantic content based only on filenames when content inspection is needed.\n\ +- For purely structural tasks such as grouping by extension, renaming obvious folders, or moving generated files, you may act from directory structure alone when that is sufficient.\n\ +- Keep changes scoped, intentional, and easy to explain.\n\ +- Preserve user content unless the request clearly asks for renaming, regrouping, cleanup, or rewrites.\n\ +- If no file changes are needed, explain that clearly instead of forcing edits.\n\ +- When you do make changes, summarize the affected paths and the reason for each group of changes.\n\n\ +[Recommended approach]\n\ +1. Start with `list_dir` to inspect the current folder layout.\n\ +2. Use `glob` and `grep` to narrow down relevant files.\n\ +3. Use `Read` on files that matter before making content-aware decisions.\n\ +4. Form a short plan.\n\ +5. Make only the necessary changes.\n\ +6. Summarize what changed and why.\n\n\ +[Desktop tools available]\n\ +- `list_dir` - List directories and files inside the selected folder\n\ +- `glob` - Find files by path patterns or extensions\n\ +- `grep` - Search file contents by text pattern\n\ +- `Read` - Read local text files\n\ +- `Write` - Create or overwrite local files\n\ +- `Edit` - Make exact text replacements in local files\n\ +- `apply_patch` - Apply structured multi-file edits\n\ +- `Bash` - Execute shell commands inside the selected folder\n\ +- `TodoWrite` - Keep a short task checklist during the run\n\ +- `desktop_skill_run` - Load the full body of a desktop skill by name. Call this first whenever you plan to process a complex document format (pdf, docx, xlsx, pptx). It returns markdown instructions and the exact `wasm_run` shape you should use next. It does NOT execute anything itself.\n\ +- `wasm_run` - Execute a WebAssembly module inside the isolated desktop runtime. Only call this after you have read the relevant skill body via `desktop_skill_run` and know the exact module name, input_json, and input_files shape to use. You may also call it directly when debugging a specific module.\n\n\ +[Desktop skills available]\n\ +Skills are packaged guidance backed by the desktop WebAssembly runtime. To use a skill, follow the two-step flow:\n\ +1. Call `desktop_skill_run(skill_name=)` to load the skill's body and operation contracts into context.\n\ +2. Follow the body: usually it tells you to call `wasm_run` with a specific module and shape. Copy that shape exactly.\n\ +Built-in skills:\n\ +- `pdf` - PDF text extraction and metadata via the `pdf_processor` isolated runtime.\n\ +- `docx` - Word document text extraction via the `docx_processor` isolated runtime.\n\ +- `xlsx` - Spreadsheet reading via the `xlsx_processor` isolated runtime (csv/tsv use host tools directly).\n\ +- `pptx` - Presentation slide text extraction via the `pptx_processor` isolated runtime.\n\ +Decision rule:\n\ +- Plain text, markdown, code, or csv/tsv files: use `Read`, `Write`, `Edit`, `grep` directly. Do not touch skills.\n\ +- `.pdf`, `.docx`, `.xlsx`, `.pptx`: call `desktop_skill_run` first to read instructions, then follow them.\n\ +- If a skill body reports that its WebAssembly module is not shipped yet, tell the user what is unavailable and offer filename-level operations instead. Never try to edit a binary container (.docx, .xlsx, .pptx are all zipped OOXML) with `Edit` or `Write` — you will corrupt the file.\n\n\ +[Local folder scope and context]\n\ +Mode scope: intelligent-folder\n\ +Input folder path: {}\n", + session.folder_tree_pair.source_root, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cowork::intelligent_folder::file_tree::{FileTreeNode, FileTreeNodeKind}; + + fn sample_folder(name: &str) -> FileTreeNode { + FileTreeNode { + id: format!("folder::{name}"), + name: name.to_string(), + kind: FileTreeNodeKind::Folder, + extension: None, + size: None, + last_modified: None, + children: Some(Vec::new()), + } + } + + fn sample_session() -> CoworkChatSessionDetail { + CoworkChatSessionDetail { + base: crate::cowork::chat::CoworkChatSessionDetail { + id: "cowork-folder-1".to_string(), + scope: crate::cowork::chat::CoworkChatScope::IntelligentFolder, + title: "demo".to_string(), + preview: "demo".to_string(), + updated_at: "2026-04-04T00:00:00.000Z".to_string(), + message_count: 0, + runtime_kind: None, + runtime_session_id: None, + messages: Vec::new(), + runtime_events: Vec::new(), + files: Vec::new(), + run_status: crate::cowork::chat::CoworkChatRunStatus::Idle, + }, + folder_tree_pair: crate::cowork::intelligent_folder::sessions::CoworkFolderTreePair { + source_root: "C:/demo".to_string(), + result_root: "C:/demo".to_string(), + source_tree: sample_folder("demo"), + result_tree: None, + }, + undo_state: crate::cowork::intelligent_folder::sessions::FolderUndoState::default(), + } + } + + #[test] + fn build_folder_prompt_context_includes_scope_and_tool_guidance() { + let prompt = build_folder_prompt_context(&sample_session()); + + assert!(prompt.contains("[Operating rules]")); + assert!(prompt.contains("[Recommended approach]")); + assert!(prompt.contains("[Desktop tools available]")); + assert!(prompt.contains("Input folder path: C:/demo")); + assert!(prompt.contains("Always read relevant files before modifying them")); + assert!(prompt.contains("Start with `list_dir` to inspect the current folder layout")); + assert!( + prompt.contains("- `list_dir` - List directories and files inside the selected folder") + ); + assert!(prompt.contains("- `Read` - Read local text files")); + assert!(prompt.contains("- `Edit` - Make exact text replacements in local files")); + assert!(prompt.contains("- `Bash` - Execute shell commands inside the selected folder")); + } + + #[test] + fn build_folder_prompt_context_advertises_two_step_flow() { + let prompt = build_folder_prompt_context(&sample_session()); + assert!(prompt.contains("- `desktop_skill_run`")); + assert!(prompt.contains("- `wasm_run`")); + assert!(prompt.contains("[Desktop skills available]")); + assert!(prompt.contains("- `pdf`")); + assert!(prompt.contains("- `docx`")); + assert!(prompt.contains("- `xlsx`")); + assert!(prompt.contains("- `pptx`")); + assert!(prompt.contains("Decision rule:")); + // Two-step flow must be spelled out explicitly. + assert!(prompt.contains("two-step flow")); + assert!(prompt.contains("desktop_skill_run(skill_name=")); + assert!(prompt.contains("Follow the body")); + // The prompt must tell the LLM not to edit binary containers. + assert!(prompt.contains("corrupt the file")); + } +} diff --git a/frontend/src-tauri/src/cowork/intelligent_folder/file_tree.rs b/frontend/src-tauri/src/cowork/intelligent_folder/file_tree.rs new file mode 100644 index 000000000..fd9363207 --- /dev/null +++ b/frontend/src-tauri/src/cowork/intelligent_folder/file_tree.rs @@ -0,0 +1,326 @@ +use chrono::{DateTime, SecondsFormat, Utc}; +use serde::{Deserialize, Serialize}; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +const DEFAULT_MAX_DEPTH: usize = 10; +const DEFAULT_MAX_ENTRIES: usize = 10_000; + +#[derive(Debug, Deserialize)] +pub struct ReadPathTreeOptions { + pub max_depth: Option, + pub max_entries: Option, + pub include_hidden: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum FileTreeNodeKind { + Folder, + File, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct FileTreeNode { + pub id: String, + pub name: String, + pub kind: FileTreeNodeKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub extension: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_modified: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub children: Option>, +} + +struct TraversalState { + max_depth: usize, + remaining_entries: usize, + max_entries: usize, + include_hidden: bool, +} + +impl TraversalState { + fn new(options: Option) -> Self { + let options = options.unwrap_or(ReadPathTreeOptions { + max_depth: None, + max_entries: None, + include_hidden: None, + }); + + let max_depth = options.max_depth.unwrap_or(DEFAULT_MAX_DEPTH); + let max_entries = options.max_entries.unwrap_or(DEFAULT_MAX_ENTRIES).max(1); + + Self { + max_depth, + remaining_entries: max_entries, + max_entries, + include_hidden: options.include_hidden.unwrap_or(false), + } + } + + fn claim_entry(&mut self) -> Result<(), String> { + if self.remaining_entries == 0 { + return Err(format!( + "Path tree exceeded the entry limit ({}) before traversal finished", + self.max_entries + )); + } + + self.remaining_entries -= 1; + Ok(()) + } +} + +#[tauri::command] +pub fn read_path_tree( + path: String, + options: Option, +) -> Result { + let trimmed = path.trim(); + if trimmed.is_empty() { + return Err("Path is required".to_string()); + } + + let root_path = PathBuf::from(trimmed); + if !root_path.exists() { + return Err(format!("Path does not exist: {}", trimmed)); + } + + let metadata = fs::symlink_metadata(&root_path) + .map_err(|error| format!("Failed to read metadata for {}: {}", trimmed, error))?; + let mut state = TraversalState::new(options); + + build_node(&root_path, metadata, 0, &mut state) +} + +fn build_node( + path: &Path, + metadata: fs::Metadata, + depth: usize, + state: &mut TraversalState, +) -> Result { + state.claim_entry()?; + + let is_symlink = metadata.file_type().is_symlink(); + let is_dir = metadata.is_dir() && !is_symlink; + let name = node_name(path); + let id = normalize_path(path); + + if !is_dir { + return Ok(FileTreeNode { + id, + name, + kind: FileTreeNodeKind::File, + extension: file_extension(path), + size: Some(format_bytes(metadata.len())), + last_modified: format_modified_time(&metadata), + children: None, + }); + } + + if depth >= state.max_depth { + return Ok(FileTreeNode { + id, + name, + kind: FileTreeNodeKind::Folder, + extension: None, + size: None, + last_modified: format_modified_time(&metadata), + children: Some(Vec::new()), + }); + } + + let mut children = Vec::new(); + let mut entries = fs::read_dir(path) + .map_err(|error| format!("Failed to read directory {}: {}", path.display(), error))? + .filter_map(|entry| entry.ok()) + .filter(|entry| state.include_hidden || !is_hidden_entry(entry)) + .filter_map(|entry| { + let child_path = entry.path(); + let metadata = fs::symlink_metadata(&child_path).ok()?; + Some((child_path, metadata)) + }) + .collect::>(); + + entries.sort_by(|(left_path, left_metadata), (right_path, right_metadata)| { + let left_is_dir = left_metadata.is_dir() && !left_metadata.file_type().is_symlink(); + let right_is_dir = right_metadata.is_dir() && !right_metadata.file_type().is_symlink(); + + right_is_dir + .cmp(&left_is_dir) + .then_with(|| { + node_name(left_path) + .to_lowercase() + .cmp(&node_name(right_path).to_lowercase()) + }) + .then_with(|| node_name(left_path).cmp(&node_name(right_path))) + }); + + for (child_path, child_metadata) in entries { + let child_node = build_node(&child_path, child_metadata, depth + 1, state)?; + children.push(child_node); + } + + Ok(FileTreeNode { + id, + name, + kind: FileTreeNodeKind::Folder, + extension: None, + size: None, + last_modified: format_modified_time(&metadata), + children: Some(children), + }) +} + +fn node_name(path: &Path) -> String { + path.file_name() + .map(|value| value.to_string_lossy().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| normalize_path(path)) +} + +fn normalize_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn file_extension(path: &Path) -> Option { + path.extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_lowercase()) + .filter(|value| !value.is_empty()) +} + +fn is_hidden_entry(entry: &fs::DirEntry) -> bool { + let is_dot_file = entry + .file_name() + .to_str() + .map(|name| name.starts_with('.')) + .unwrap_or(false); + + if is_dot_file { + return true; + } + + #[cfg(target_os = "windows")] + { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2; + + if let Ok(metadata) = entry.metadata() { + return metadata.file_attributes() & FILE_ATTRIBUTE_HIDDEN != 0; + } + } + + false +} + +fn format_bytes(bytes: u64) -> String { + const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; + + if bytes < 1024 { + return format!("{} B", bytes); + } + + let mut value = bytes as f64; + let mut unit_index = 0usize; + + while value >= 1024.0 && unit_index < UNITS.len() - 1 { + value /= 1024.0; + unit_index += 1; + } + + if value >= 100.0 || value.fract() < 0.05 { + format!("{:.0} {}", value, UNITS[unit_index]) + } else { + format!("{:.1} {}", value, UNITS[unit_index]) + } +} + +fn format_modified_time(metadata: &fs::Metadata) -> Option { + let modified = metadata.modified().ok()?; + let date_time = DateTime::::from(modified); + Some(date_time.to_rfc3339_opts(SecondsFormat::Secs, true)) +} + +#[cfg(test)] +mod tests { + use super::{ + build_node, format_bytes, format_modified_time, FileTreeNodeKind, TraversalState, + }; + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + fn temp_test_dir() -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos(); + + std::env::temp_dir().join(format!("ii-agent-tauri-tree-{unique}")) + } + + #[test] + fn formats_bytes_for_ui() { + assert_eq!(format_bytes(512), "512 B"); + assert_eq!(format_bytes(1_536), "1.5 KB"); + assert_eq!(format_bytes(104_857_600), "100 MB"); + } + + #[test] + fn formats_modified_time_as_rfc3339() { + let root = temp_test_dir(); + fs::write(&root, b"hello world").expect("should create test file"); + + let metadata = fs::symlink_metadata(&root).expect("should read metadata"); + let modified = format_modified_time(&metadata) + .expect("test file metadata should expose modified time"); + + assert!(modified.ends_with('Z')); + assert!(modified.contains('T')); + + fs::remove_file(&root).expect("should clean up test file"); + } + + #[test] + fn builds_directory_tree() { + let root = temp_test_dir(); + let nested = root.join("nested"); + let file_path = nested.join("demo.txt"); + + fs::create_dir_all(&nested).expect("should create test directories"); + fs::write(&file_path, b"hello world").expect("should create test file"); + + let metadata = fs::symlink_metadata(&root).expect("should read root metadata"); + let mut state = TraversalState::new(None); + let tree = build_node(&root, metadata, 0, &mut state).expect("should build tree"); + + assert_eq!(tree.kind, FileTreeNodeKind::Folder); + assert_eq!(tree.name, root.to_string_lossy().replace('\\', "/")); + + let children = tree.children.expect("folder should include children"); + assert_eq!(children.len(), 1); + assert_eq!(children[0].name, "nested"); + assert_eq!(children[0].kind, FileTreeNodeKind::Folder); + + let nested_children = children[0] + .children + .clone() + .expect("nested folder should include children"); + assert_eq!(nested_children.len(), 1); + assert_eq!(nested_children[0].name, "demo.txt"); + assert_eq!(nested_children[0].kind, FileTreeNodeKind::File); + assert_eq!(nested_children[0].extension.as_deref(), Some("txt")); + assert!(nested_children[0].last_modified.is_some()); + + fs::remove_dir_all(&root).expect("should clean up test directory"); + } +} diff --git a/frontend/src-tauri/src/cowork/intelligent_folder/mod.rs b/frontend/src-tauri/src/cowork/intelligent_folder/mod.rs new file mode 100644 index 000000000..b4c7aa5ea --- /dev/null +++ b/frontend/src-tauri/src/cowork/intelligent_folder/mod.rs @@ -0,0 +1,6 @@ +pub mod capabilities; +pub mod chat_prompt; +pub mod file_tree; +pub mod sessions; +pub mod snapshot_store; +pub mod undo_commands; diff --git a/frontend/src-tauri/src/cowork/intelligent_folder/sessions.rs b/frontend/src-tauri/src/cowork/intelligent_folder/sessions.rs new file mode 100644 index 000000000..03dd54832 --- /dev/null +++ b/frontend/src-tauri/src/cowork/intelligent_folder/sessions.rs @@ -0,0 +1,542 @@ +pub use crate::cowork::chat::{CoworkChatRunStatus, CoworkChatScope, CoworkChatSessionSummary}; + +use crate::cowork::chat::CoworkChatSessionDetail as BaseCoworkChatSessionDetail; +use crate::cowork::intelligent_folder::file_tree::{self, FileTreeNode, FileTreeNodeKind}; +use chrono::{SecondsFormat, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + fs, + ops::{Deref, DerefMut}, + path::PathBuf, +}; +use tauri::{AppHandle, Manager}; + +const STORE_DIR_NAME: &str = "cowork"; +const SESSION_STORE_DIR_NAME: &str = "folder-sessions"; +const LEGACY_STORE_FILE_NAME: &str = "folder-sessions.json"; +const FOLDER_SNAPSHOTS_DIR_NAME: &str = "folder-snapshots"; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct CoworkFolderTreePair { + pub source_root: String, + pub result_root: String, + pub source_tree: FileTreeNode, + pub result_tree: Option, +} + +/// Lightweight UI hint mirroring the on-disk `timeline.json` for a folder +/// session's snapshot history. The authoritative timeline lives in +/// `{app_data}/cowork/folder-snapshots/{session_id}/timeline.json`; this +/// struct caches just enough state on the session JSON for the frontend +/// to render the Undo/Redo pill with a "current/total" counter without +/// having to load the timeline file on every render. +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)] +pub struct FolderUndoState { + /// Is there at least one earlier snapshot to undo to? + pub can_undo: bool, + /// Is there at least one later snapshot to redo into? + pub can_redo: bool, + /// 1-based index of the snapshot currently on disk. `0` when the + /// timeline is empty (no snapshots yet) — in that case `total` is + /// also `0` and the UI hides the pill. + pub current: usize, + /// Total number of snapshots in the timeline. `0` when empty. + pub total: usize, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct CoworkChatSessionDetail { + #[serde(flatten)] + pub base: BaseCoworkChatSessionDetail, + pub folder_tree_pair: CoworkFolderTreePair, + #[serde(default)] + pub undo_state: FolderUndoState, +} + +impl Deref for CoworkChatSessionDetail { + type Target = BaseCoworkChatSessionDetail; + + fn deref(&self) -> &Self::Target { + &self.base + } +} + +impl DerefMut for CoworkChatSessionDetail { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.base + } +} + +#[derive(Debug, Serialize, Deserialize, Default)] +struct FolderSessionStore { + sessions: Vec, +} + +#[tauri::command] +pub fn list_folder_sessions(app: AppHandle) -> Result, String> { + let mut sessions = load_sessions(&app)?; + sort_sessions(&mut sessions); + + Ok(sessions.into_iter().map(build_summary).collect()) +} + +#[tauri::command] +pub fn get_folder_session( + app: AppHandle, + session_id: String, +) -> Result { + load_session(&app, &session_id) +} + +#[tauri::command] +pub fn create_folder_session( + app: AppHandle, + tree_pair: CoworkFolderTreePair, +) -> Result { + validate_tree_pair(&tree_pair)?; + + let now = now_iso(); + let session = CoworkChatSessionDetail { + base: BaseCoworkChatSessionDetail { + id: generate_session_id(), + scope: CoworkChatScope::IntelligentFolder, + title: build_session_title(&tree_pair.source_root), + preview: build_session_preview(&tree_pair.source_root), + updated_at: now, + message_count: 0, + runtime_kind: None, + runtime_session_id: None, + messages: Vec::new(), + runtime_events: Vec::new(), + files: Vec::new(), + run_status: CoworkChatRunStatus::Idle, + }, + folder_tree_pair: tree_pair, + undo_state: FolderUndoState::default(), + }; + + write_session(&app, &session)?; + + Ok(session) +} + +#[tauri::command] +pub fn update_folder_session( + app: AppHandle, + session: CoworkChatSessionDetail, +) -> Result { + validate_session(&session)?; + + let normalized_session = normalize_session(session); + + write_session(&app, &normalized_session)?; + + Ok(normalized_session) +} + +#[tauri::command] +pub fn rename_folder_session( + app: AppHandle, + session_id: String, + title: String, +) -> Result { + let normalized_title = title.trim(); + if normalized_title.is_empty() { + return Err("Session title is required".to_string()); + } + + let mut session = load_session(&app, &session_id)?; + + session.title = normalized_title.to_string(); + session.updated_at = now_iso(); + + write_session(&app, &session)?; + + Ok(session) +} + +#[tauri::command] +pub fn delete_folder_session(app: AppHandle, session_id: String) -> Result<(), String> { + // Snapshot dir cleanup is best-effort: if it fails we still delete the + // session file so the user isn't blocked. A dangling snapshot dir only + // wastes disk until the user deletes the app data directory. + if let Ok(snapshot_dir) = folder_snapshot_session_dir(&app, &session_id) { + if snapshot_dir.exists() { + if let Err(error) = fs::remove_dir_all(&snapshot_dir) { + eprintln!( + "[cowork] failed to clean up folder snapshot dir {}: {}", + snapshot_dir.display(), + error + ); + } + } + } + + delete_session(&app, &session_id) +} + +/// Absolute path to `{app_data}/cowork/folder-snapshots/` (ensures it exists). +pub fn folder_snapshots_base_dir(app: &AppHandle) -> Result { + let mut store_root = store_root_dir_path(app)?; + store_root.push(FOLDER_SNAPSHOTS_DIR_NAME); + + fs::create_dir_all(&store_root).map_err(|error| { + format!( + "Failed to create folder snapshots directory {}: {}", + store_root.display(), + error + ) + })?; + + Ok(store_root) +} + +/// Absolute path to the per-session snapshot directory (does NOT create it). +/// The snapshot store is responsible for lazy-creating the subdirs it needs. +pub fn folder_snapshot_session_dir( + app: &AppHandle, + session_id: &str, +) -> Result { + let normalized = normalize_session_id(session_id)?; + let mut dir = folder_snapshots_base_dir(app)?; + dir.push(normalized); + Ok(dir) +} + +pub fn sync_result_tree_from_disk(session: &mut CoworkChatSessionDetail) -> Result<(), String> { + let latest_tree = + file_tree::read_path_tree(session.folder_tree_pair.source_root.clone(), None)?; + let source_hash = hash_tree(&session.folder_tree_pair.source_tree)?; + let latest_hash = hash_tree(&latest_tree)?; + + session.folder_tree_pair.result_root = session.folder_tree_pair.source_root.clone(); + session.folder_tree_pair.result_tree = if source_hash == latest_hash { + None + } else { + Some(latest_tree) + }; + + Ok(()) +} + +fn validate_session(session: &CoworkChatSessionDetail) -> Result<(), String> { + if session.scope != CoworkChatScope::IntelligentFolder { + return Err("Only intelligent-folder sessions can be persisted locally".to_string()); + } + + validate_tree_pair(&session.folder_tree_pair) +} + +pub fn hash_tree(tree: &FileTreeNode) -> Result { + let serialized = serde_json::to_vec(tree) + .map_err(|error| format!("Failed to serialize folder tree for hashing: {}", error))?; + let digest = Sha256::digest(serialized); + Ok(digest.iter().map(|value| format!("{value:02x}")).collect()) +} + +fn validate_tree_pair(tree_pair: &CoworkFolderTreePair) -> Result<(), String> { + if tree_pair.source_root.trim().is_empty() { + return Err("Folder session source_root is required".to_string()); + } + + if tree_pair.result_root.trim().is_empty() { + return Err("Folder session result_root is required".to_string()); + } + + if tree_pair.source_tree.kind != FileTreeNodeKind::Folder { + return Err("Folder session source_tree root must be a folder".to_string()); + } + + if let Some(result_tree) = &tree_pair.result_tree { + if result_tree.kind != FileTreeNodeKind::Folder { + return Err("Folder session result_tree root must be a folder".to_string()); + } + } + + Ok(()) +} + +fn build_summary(session: CoworkChatSessionDetail) -> CoworkChatSessionSummary { + let normalized_session = normalize_session(session); + + CoworkChatSessionSummary { + id: normalized_session.base.id, + scope: normalized_session.base.scope, + title: normalized_session.base.title, + preview: normalized_session.base.preview, + updated_at: normalized_session.base.updated_at, + message_count: normalized_session.base.message_count, + } +} + +fn build_session_title(source_root: &str) -> String { + let trimmed = source_root.trim().trim_end_matches(['\\', '/']); + let folder_name = trimmed + .rsplit(['\\', '/']) + .find(|segment| !segment.is_empty()) + .unwrap_or(trimmed); + + if folder_name.is_empty() { + "Folder session".to_string() + } else { + folder_name.to_string() + } +} + +fn build_session_preview(source_root: &str) -> String { + format!("Source: {}", build_session_title(source_root)) +} + +fn generate_session_id() -> String { + format!("cowork-folder-{}", Utc::now().timestamp_millis()) +} + +fn now_iso() -> String { + Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true) +} + +fn normalize_session(mut session: CoworkChatSessionDetail) -> CoworkChatSessionDetail { + session.base.normalize_runtime_binding(); + session.base.message_count = session.base.messages.len(); + session.base.preview = build_session_preview(&session.folder_tree_pair.source_root); + if session.base.messages.is_empty() + && session.base.runtime_events.is_empty() + && session.base.runtime_session_id.is_none() + && session.base.run_status == CoworkChatRunStatus::Completed + { + session.base.run_status = CoworkChatRunStatus::Idle; + } + + session +} + +fn sort_sessions(sessions: &mut [CoworkChatSessionDetail]) { + sessions.sort_by(|left, right| { + right + .updated_at + .cmp(&left.updated_at) + .then_with(|| right.id.cmp(&left.id)) + }); +} + +fn load_sessions(app: &AppHandle) -> Result, String> { + migrate_legacy_store(app)?; + + let store_dir = session_store_dir_path(app)?; + let entries = fs::read_dir(&store_dir).map_err(|error| { + format!( + "Failed to read folder sessions directory {}: {}", + store_dir.display(), + error + ) + })?; + let mut sessions = Vec::new(); + + for entry in entries { + let entry = entry.map_err(|error| { + format!( + "Failed to read an entry in folder sessions directory {}: {}", + store_dir.display(), + error + ) + })?; + let path = entry.path(); + + if !path.is_file() || path.extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + + sessions.push(normalize_session(read_session_file(&path)?)); + } + + Ok(sessions) +} + +fn load_session(app: &AppHandle, session_id: &str) -> Result { + migrate_legacy_store(app)?; + + let session_file = session_file_path(app, session_id)?; + if !session_file.exists() { + return Err(format!("Folder session not found: {}", session_id.trim())); + } + + Ok(normalize_session(read_session_file(&session_file)?)) +} + +fn write_session(app: &AppHandle, session: &CoworkChatSessionDetail) -> Result<(), String> { + migrate_legacy_store(app)?; + + let session_file = session_file_path(app, &session.id)?; + let contents = serde_json::to_string_pretty(session).map_err(|error| { + format!( + "Failed to serialize folder session {}: {}", + session.id, error + ) + })?; + + fs::write(&session_file, contents).map_err(|error| { + format!( + "Failed to write folder session file {}: {}", + session_file.display(), + error + ) + }) +} + +fn delete_session(app: &AppHandle, session_id: &str) -> Result<(), String> { + migrate_legacy_store(app)?; + + let session_file = session_file_path(app, session_id)?; + if !session_file.exists() { + return Err(format!("Folder session not found: {}", session_id.trim())); + } + + fs::remove_file(&session_file).map_err(|error| { + format!( + "Failed to delete folder session file {}: {}", + session_file.display(), + error + ) + }) +} + +fn read_session_file(path: &PathBuf) -> Result { + let contents = fs::read_to_string(path).map_err(|error| { + format!( + "Failed to read folder session file {}: {}", + path.display(), + error + ) + })?; + + if contents.trim().is_empty() { + return Err(format!( + "Folder session file is empty: {}", + path.display() + )); + } + + serde_json::from_str(&contents).map_err(|error| { + format!( + "Failed to parse folder session file {}: {}", + path.display(), + error + ) + }) +} + +fn migrate_legacy_store(app: &AppHandle) -> Result<(), String> { + let legacy_store_file = legacy_store_file_path(app)?; + if !legacy_store_file.exists() { + return Ok(()); + } + + let contents = fs::read_to_string(&legacy_store_file) + .map_err(|error| format!("Failed to read folder session store: {}", error))?; + + if contents.trim().is_empty() { + fs::remove_file(&legacy_store_file).map_err(|error| { + format!( + "Failed to remove empty legacy folder session store {}: {}", + legacy_store_file.display(), + error + ) + })?; + return Ok(()); + } + + let legacy_store: FolderSessionStore = serde_json::from_str(&contents) + .map_err(|error| format!("Failed to parse folder session store: {}", error))?; + + for session in legacy_store.sessions { + let session_file = session_file_path(app, &session.id)?; + let session_contents = serde_json::to_string_pretty(&session).map_err(|error| { + format!( + "Failed to serialize migrated folder session {}: {}", + session.id, error + ) + })?; + + fs::write(&session_file, session_contents).map_err(|error| { + format!( + "Failed to write migrated folder session file {}: {}", + session_file.display(), + error + ) + })?; + } + + fs::remove_file(&legacy_store_file).map_err(|error| { + format!( + "Failed to remove legacy folder session store {}: {}", + legacy_store_file.display(), + error + ) + }) +} + +fn normalize_session_id(session_id: &str) -> Result { + let trimmed = session_id.trim(); + if trimmed.is_empty() { + return Err("Folder session id is required".to_string()); + } + + if trimmed == "." || trimmed == ".." { + return Err(format!("Invalid folder session id: {}", trimmed)); + } + + if trimmed.chars().any(|character| { + character.is_control() + || matches!( + character, + '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' + ) + }) { + return Err(format!("Invalid folder session id: {}", trimmed)); + } + + Ok(trimmed.to_string()) +} + +fn store_root_dir_path(app: &AppHandle) -> Result { + let mut data_dir = app + .path() + .app_data_dir() + .map_err(|error| format!("Failed to resolve app data directory: {}", error))?; + + data_dir.push(STORE_DIR_NAME); + + fs::create_dir_all(&data_dir) + .map_err(|error| format!("Failed to create folder session directory: {}", error))?; + + Ok(data_dir) +} + +fn session_store_dir_path(app: &AppHandle) -> Result { + let mut store_root = store_root_dir_path(app)?; + store_root.push(SESSION_STORE_DIR_NAME); + + fs::create_dir_all(&store_root).map_err(|error| { + format!( + "Failed to create folder sessions directory {}: {}", + store_root.display(), + error + ) + })?; + + Ok(store_root) +} + +fn legacy_store_file_path(app: &AppHandle) -> Result { + let mut store_root = store_root_dir_path(app)?; + store_root.push(LEGACY_STORE_FILE_NAME); + Ok(store_root) +} + +fn session_file_path(app: &AppHandle, session_id: &str) -> Result { + let normalized_session_id = normalize_session_id(session_id)?; + let mut store_dir = session_store_dir_path(app)?; + store_dir.push(format!("{normalized_session_id}.json")); + Ok(store_dir) +} diff --git a/frontend/src-tauri/src/cowork/intelligent_folder/snapshot_store.rs b/frontend/src-tauri/src/cowork/intelligent_folder/snapshot_store.rs new file mode 100644 index 000000000..3a61d01a8 --- /dev/null +++ b/frontend/src-tauri/src/cowork/intelligent_folder/snapshot_store.rs @@ -0,0 +1,1835 @@ +//! Content-addressable snapshot store with timeline history for cowork +//! Intelligent Folder sessions. +//! +//! Each folder session gets a **timeline** of snapshots (up to +//! [`MAX_TIMELINE_LEN`] deep) backed by a shared content-addressable +//! blob pool: +//! +//! ```text +//! {app_data}/cowork/folder-snapshots/{session_id}/ +//! ├── blobs/ +//! │ ├── ab/ +//! │ │ └── abc…def # file bytes, filename = full sha256 +//! │ └── 7f/ +//! │ └── 7f9a… +//! ├── snapshots/ +//! │ ├── 001-01HRAB….json # per-snapshot manifest +//! │ ├── 002-01HRAC….json +//! │ └── … +//! ├── timeline.json # ordered list + cursor +//! └── pending.json # pre-run marker (transient) +//! ``` +//! +//! ### Blob pool (content-addressable) +//! +//! Every file is hashed (SHA-256) and its bytes are written to +//! `blobs/{first2}/{full_hash}` exactly once — identical contents dedupe +//! automatically across snapshots. This is what keeps a long timeline +//! cheap: if the agent only modifies 5 files out of 10k between +//! snapshots, only those 5 new blobs are added to the pool. +//! +//! ### Timeline ([`Timeline`]) +//! +//! `timeline.json` is the authoritative ordered history: +//! +//! - `snapshots[i]` holds a [`SnapshotMeta`] pointing at a manifest file +//! and recording the file-tree hash of the disk state it captured. +//! - `cursor` is the index of the snapshot **currently materialised on +//! disk**. `cursor == snapshots.len() - 1` means the user is at the +//! newest checkpoint; `cursor < len - 1` means they've undone one or +//! more steps ("detached"). +//! - Undo/Redo move the cursor by ±1 and re-materialise the corresponding +//! manifest on disk. +//! - Sending a new chat message while detached **truncates** +//! `snapshots[cursor+1..]` (git-style), then appends the fresh +//! post-run state. Old future branches are dropped. +//! - If the timeline grows past [`MAX_TIMELINE_LEN`], the oldest entry is +//! dropped and the cursor shifts to compensate. +//! +//! ### Pending marker ([`PendingSnapshot`]) +//! +//! Captured at run start (pre-run hook in `session_gateway`). Its +//! `pre_run_tree_hash` lets the post-run code cheaply decide "did disk +//! actually change?" without comparing whole manifests. Its `manifest` +//! is used to *seed* the timeline when it's empty (or to *rebase* it +//! when the current cursor entry is out-of-sync with disk, e.g. due to +//! external modification). On every commit/discard the pending file is +//! cleared and orphaned blobs GC'd. +//! +//! ### Crash safety +//! +//! - Blobs, manifest files, `timeline.json`, and `pending.json` are all +//! written via `*.tmp` → `rename`, so readers only ever see fully +//! formed content. +//! - The materialise step (copying bytes from blobs back to +//! `source_root`) uses per-file tmp + rename. A crash mid-apply leaves +//! the user's folder in a mixed state — same risk profile as the +//! agent itself. Documented as a known limitation. +//! - Timeline truncation and retention writes are a single atomic rename +//! of `timeline.json`; manifest files left behind from a half-finished +//! truncation are reclaimed by the next GC pass. +//! +//! Symlinks are stored as-is (target path recorded, not followed). All +//! files including hidden, `.git`, `node_modules`, etc. are included — +//! no ignore list, by the user's explicit choice. + +use super::sessions::folder_snapshot_session_dir; +use chrono::{SecondsFormat, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + collections::{HashMap, HashSet}, + fs, + io::{self, Read}, + path::{Component, Path, PathBuf}, + time::UNIX_EPOCH, +}; +use tauri::AppHandle; + +const BLOBS_DIR_NAME: &str = "blobs"; +const BLOB_SUFFIX: &str = ".zst"; +const SNAPSHOTS_DIR_NAME: &str = "snapshots"; +const TIMELINE_FILE_NAME: &str = "timeline.json"; +const TIMELINE_TMP_FILE_NAME: &str = "timeline.json.tmp"; +const PENDING_FILE_NAME: &str = "pending.json"; +const PENDING_TMP_FILE_NAME: &str = "pending.json.tmp"; +const STAT_CACHE_FILE_NAME: &str = "stat-cache.json"; +const STAT_CACHE_TMP_FILE_NAME: &str = "stat-cache.json.tmp"; + +/// Zstd compression level. 3 is the default — good compression ratio on +/// text (~60-75% reduction) with minimal CPU overhead (~400 MB/s on +/// modern CPUs). Higher levels plateau quickly for code/text content. +const ZSTD_LEVEL: i32 = 3; + +/// Maximum number of snapshots retained per session. Older entries are +/// dropped from the front of the timeline (with cursor shifted to +/// compensate) when a new snapshot would push the list past this limit. +/// Keeps disk usage bounded — worst case ~20× the size of files changed +/// across the retained runs, thanks to CAS dedup. +pub const MAX_TIMELINE_LEN: usize = 20; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case", tag = "kind")] +pub enum ManifestEntry { + Dir { + rel_path: String, + }, + File { + rel_path: String, + sha256: String, + }, + Symlink { + rel_path: String, + target: String, + }, +} + +impl ManifestEntry { + fn rel_path(&self) -> &str { + match self { + ManifestEntry::Dir { rel_path } + | ManifestEntry::File { rel_path, .. } + | ManifestEntry::Symlink { rel_path, .. } => rel_path, + } + } +} + +/// A single captured disk state: the sorted list of every file, directory, +/// and symlink under `source_root`, with file content referenced by sha256 +/// into the shared blob pool. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct Manifest { + pub entries: Vec, +} + +/// Lightweight descriptor of one snapshot recorded in `timeline.json`. +/// The heavy manifest lives in its own file (`snapshots/{id}.json`) so +/// the timeline file stays small and cheap to read/rewrite. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct SnapshotMeta { + /// Opaque ID used as the manifest filename (`snapshots/{id}.json`). + /// Monotonic-ish so lexicographic sort matches creation order, with a + /// trailing random tag to avoid collisions if two runs finish in the + /// same millisecond. + pub id: String, + /// ISO-8601 timestamp (millis precision, UTC). + pub created_at: String, + /// Hash of the file-tree (via `folder_sessions::hash_tree`) of the + /// disk state this snapshot captures. Used by the post-run hook to + /// cheaply check "does `timeline[cursor]` still match current disk?" + /// without having to re-materialise the manifest. + pub disk_tree_hash: String, +} + +/// Persisted history for one session. `cursor` is always in +/// `0..snapshots.len()` — an empty timeline is valid (`cursor == 0`, +/// no snapshots). +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)] +pub struct Timeline { + pub snapshots: Vec, + pub cursor: usize, +} + +impl Timeline { + /// Clamp cursor to a valid index on load, in case the file was + /// corrupted or externally edited. + fn sanitized(mut self) -> Self { + if self.snapshots.is_empty() { + self.cursor = 0; + } else if self.cursor >= self.snapshots.len() { + self.cursor = self.snapshots.len() - 1; + } + self + } + + pub fn can_undo(&self) -> bool { + self.cursor > 0 && !self.snapshots.is_empty() + } + + pub fn can_redo(&self) -> bool { + !self.snapshots.is_empty() && self.cursor + 1 < self.snapshots.len() + } + + /// 1-based index of the current snapshot for UI display + /// (`"{current}/{total}"`). Returns `(0, 0)` when the timeline is + /// empty so the UI can hide itself. + pub fn display_position(&self) -> (usize, usize) { + if self.snapshots.is_empty() { + (0, 0) + } else { + (self.cursor + 1, self.snapshots.len()) + } + } +} + +/// Cache entry for a single file, used to skip re-hashing unchanged +/// files during snapshot scans. Indexed by relative path. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct StatCacheEntry { + /// Modification time in nanoseconds since UNIX epoch. + pub mtime_nanos: u128, + /// File size in bytes. + pub size: u64, + /// SHA-256 of the **uncompressed** file contents. Same as the blob + /// pool key, so we can skip both the `fs::read` and the `sha2` hash + /// computation on a cache hit. + pub sha256: String, +} + +/// Persisted stat cache: `rel_path → StatCacheEntry`. Lives in +/// `{session_dir}/stat-cache.json` and is rewritten atomically after +/// every snapshot scan. Safe to delete externally — a missing cache +/// just means the next scan will re-hash everything. +/// +/// **Correctness note**: mtime can lie on some filesystems (clones, +/// NFS, Docker volumes). If it does, we'll reuse a stale sha256 → +/// create a manifest pointing at the OLD blob → the user's disk state +/// appears unchanged when it isn't. Worst case: a change gets missed, +/// undo can't restore it. Best case: user triggers a subsequent run +/// that touches the same file via a real content edit, the mtime +/// updates, and the cache catches up. +/// +/// This is an acceptable trade-off because: (1) this is a UI undo +/// buffer, not a backup tool; (2) Git uses the same trick and it +/// works fine for 99% of workflows; (3) the CAS layer below us +/// prevents any actual data corruption — the worst outcome is +/// suboptimal undo. +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct StatCache { + pub entries: HashMap, +} + +/// Pre-run marker: a frozen snapshot of disk taken the moment a new agent +/// run started, held on disk so the post-run code can compare against the +/// live disk state (even after process restart) and decide whether to +/// commit the snapshot into the timeline. +/// +/// `pre_run_tree_hash` is the `hash_tree(FileTreeNode)` value of the +/// pre-run disk state. It's stored alongside the manifest so the post-run +/// comparison can be done without having to re-materialize the pre-run +/// state from blobs — we just re-scan disk, hash the result, and compare. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct PendingSnapshot { + pub pre_run_tree_hash: String, + pub manifest: Manifest, +} + +/// Store bound to a single cowork folder session. +pub struct SnapshotStore { + session_dir: PathBuf, +} + +impl SnapshotStore { + /// Resolve the per-session snapshot directory from the app handle. Does + /// not create any files — callers hit [`Self::snapshot`], [`Self::apply`], + /// etc. which lazy-create subdirs as needed. + pub fn for_session(app: &AppHandle, session_id: &str) -> Result { + let session_dir = folder_snapshot_session_dir(app, session_id)?; + Ok(Self { session_dir }) + } + + /// Test-only constructor: bypasses the Tauri app handle and uses the + /// provided directory directly. + #[cfg(test)] + pub fn from_dir(session_dir: PathBuf) -> Self { + Self { session_dir } + } + + fn blobs_dir(&self) -> PathBuf { + self.session_dir.join(BLOBS_DIR_NAME) + } + + fn snapshots_dir(&self) -> PathBuf { + self.session_dir.join(SNAPSHOTS_DIR_NAME) + } + + fn timeline_path(&self) -> PathBuf { + self.session_dir.join(TIMELINE_FILE_NAME) + } + + fn timeline_tmp_path(&self) -> PathBuf { + self.session_dir.join(TIMELINE_TMP_FILE_NAME) + } + + fn pending_path(&self) -> PathBuf { + self.session_dir.join(PENDING_FILE_NAME) + } + + fn pending_tmp_path(&self) -> PathBuf { + self.session_dir.join(PENDING_TMP_FILE_NAME) + } + + fn manifest_path(&self, snapshot_id: &str) -> PathBuf { + self.snapshots_dir().join(format!("{snapshot_id}.json")) + } + + fn manifest_tmp_path(&self, snapshot_id: &str) -> PathBuf { + self.snapshots_dir().join(format!("{snapshot_id}.json.tmp")) + } + + fn stat_cache_path(&self) -> PathBuf { + self.session_dir.join(STAT_CACHE_FILE_NAME) + } + + fn stat_cache_tmp_path(&self) -> PathBuf { + self.session_dir.join(STAT_CACHE_TMP_FILE_NAME) + } + + /// Absolute path to a blob's on-disk location. All blob files are + /// zstd-compressed (`.zst` suffix); the sha256 in the filename + /// always refers to the **uncompressed** contents so dedup keys + /// stay consistent with caller-visible hashes. + fn blob_path_for(&self, hash: &str) -> PathBuf { + // Shard by first 2 hex chars to keep any one directory small. + let mut path = self.blobs_dir(); + path.push(&hash[..2]); + path.push(format!("{hash}{BLOB_SUFFIX}")); + path + } + + /// Load the persisted stat cache for this session. Returns an empty + /// cache when the file is missing or corrupt — the next scan will + /// re-hash everything and rebuild it from scratch, which is slower + /// but always correct. + pub fn read_stat_cache(&self) -> StatCache { + let path = self.stat_cache_path(); + if !path.exists() { + return StatCache::default(); + } + match fs::read_to_string(&path) { + Ok(contents) => serde_json::from_str(&contents).unwrap_or_else(|error| { + eprintln!( + "[cowork] stat-cache: failed to parse {} ({}), resetting", + path.display(), + error + ); + StatCache::default() + }), + Err(error) => { + eprintln!( + "[cowork] stat-cache: failed to read {} ({}), resetting", + path.display(), + error + ); + StatCache::default() + } + } + } + + /// Atomically persist the stat cache. Best-effort — a failure here + /// is logged but not propagated, because losing the cache only + /// degrades snapshot speed, never correctness. + pub fn write_stat_cache(&self, cache: &StatCache) { + if let Err(error) = self.try_write_stat_cache(cache) { + eprintln!( + "[cowork] stat-cache: failed to persist cache: {}", + error + ); + } + } + + fn try_write_stat_cache(&self, cache: &StatCache) -> Result<(), String> { + fs::create_dir_all(&self.session_dir).map_err(|error| { + format!( + "Failed to create session dir {}: {}", + self.session_dir.display(), + error + ) + })?; + let tmp_path = self.stat_cache_tmp_path(); + let serialized = serde_json::to_vec(cache).map_err(|error| { + format!("Failed to serialize stat cache: {}", error) + })?; + fs::write(&tmp_path, serialized).map_err(|error| { + format!( + "Failed to write stat cache tmp file {}: {}", + tmp_path.display(), + error + ) + })?; + fs::rename(&tmp_path, self.stat_cache_path()).map_err(|error| { + format!( + "Failed to commit stat cache to {}: {}", + self.stat_cache_path().display(), + error + ) + }) + } + + /// Walk `source_root` recursively, copy every unique file into the + /// blob pool, and return an in-memory manifest. The returned + /// manifest is not persisted yet — the caller chooses whether to + /// commit it via [`Self::push_snapshot`] or discard it by calling + /// [`Self::gc_unreferenced_blobs`] without referencing its blobs + /// anywhere. + /// + /// Two storage optimizations are applied during the walk: + /// + /// 1. **Stat cache** avoids hashing files whose `(mtime, size)` + /// haven't changed since the last scan, reusing the previously + /// computed sha256. Saves ~50× snapshot time on subsequent runs. + /// 2. **Zstd compression** shrinks text-heavy blobs 60-80% (applied + /// inside [`Self::store_blob`]). + pub fn snapshot(&self, source_root: &Path) -> Result { + if !source_root.exists() { + return Err(format!( + "Snapshot source does not exist: {}", + source_root.display() + )); + } + if !source_root.is_dir() { + return Err(format!( + "Snapshot source is not a directory: {}", + source_root.display() + )); + } + + fs::create_dir_all(self.blobs_dir()).map_err(|error| { + format!( + "Failed to create snapshot blobs dir {}: {}", + self.blobs_dir().display(), + error + ) + })?; + + let previous_cache = self.read_stat_cache(); + let mut next_cache = StatCache::default(); + let mut entries = Vec::new(); + walk_source_tree( + source_root, + source_root, + &mut entries, + self, + &previous_cache, + &mut next_cache, + )?; + // Sort pre-order by rel_path so manifests are deterministic regardless + // of OS `read_dir` ordering. + entries.sort_by(|left, right| left.rel_path().cmp(right.rel_path())); + + // Best-effort persist the refreshed cache for the next scan. + self.write_stat_cache(&next_cache); + + Ok(Manifest { entries }) + } + + /// Read the persisted timeline. Returns an empty timeline (cursor=0, + /// snapshots=[]) if `timeline.json` does not exist. Applies a clamp + /// to `cursor` so a corrupted/hand-edited file can't break callers + /// that assume `cursor` is a valid index. + pub fn read_timeline(&self) -> Result { + let timeline_path = self.timeline_path(); + if !timeline_path.exists() { + return Ok(Timeline::default()); + } + + let contents = fs::read_to_string(&timeline_path).map_err(|error| { + format!( + "Failed to read timeline {}: {}", + timeline_path.display(), + error + ) + })?; + + let timeline: Timeline = serde_json::from_str(&contents).map_err(|error| { + format!( + "Failed to parse timeline {}: {}", + timeline_path.display(), + error + ) + })?; + + Ok(timeline.sanitized()) + } + + /// Atomically replace `timeline.json` on disk. Writes to a sibling + /// `.tmp` file first and then renames into place so readers never + /// see partial content. + pub fn write_timeline(&self, timeline: &Timeline) -> Result<(), String> { + fs::create_dir_all(&self.session_dir).map_err(|error| { + format!( + "Failed to create session snapshot dir {}: {}", + self.session_dir.display(), + error + ) + })?; + + let tmp_path = self.timeline_tmp_path(); + let serialized = serde_json::to_vec_pretty(timeline).map_err(|error| { + format!("Failed to serialize timeline: {}", error) + })?; + fs::write(&tmp_path, serialized).map_err(|error| { + format!( + "Failed to write timeline tmp file {}: {}", + tmp_path.display(), + error + ) + })?; + fs::rename(&tmp_path, self.timeline_path()).map_err(|error| { + format!( + "Failed to commit timeline to {}: {}", + self.timeline_path().display(), + error + ) + })?; + + Ok(()) + } + + /// Read a snapshot manifest by id. Used when Undo/Redo needs to + /// materialize a specific timeline entry on disk. + pub fn read_manifest(&self, snapshot_id: &str) -> Result { + let path = self.manifest_path(snapshot_id); + let contents = fs::read_to_string(&path).map_err(|error| { + format!( + "Failed to read snapshot manifest {}: {}", + path.display(), + error + ) + })?; + serde_json::from_str(&contents).map_err(|error| { + format!( + "Failed to parse snapshot manifest {}: {}", + path.display(), + error + ) + }) + } + + /// Atomically write a snapshot manifest. Caller is responsible for + /// choosing a unique `snapshot_id`. + fn write_manifest( + &self, + snapshot_id: &str, + manifest: &Manifest, + ) -> Result<(), String> { + fs::create_dir_all(self.snapshots_dir()).map_err(|error| { + format!( + "Failed to create snapshots dir {}: {}", + self.snapshots_dir().display(), + error + ) + })?; + + let tmp_path = self.manifest_tmp_path(snapshot_id); + let serialized = serde_json::to_vec_pretty(manifest).map_err(|error| { + format!("Failed to serialize manifest {snapshot_id}: {error}") + })?; + fs::write(&tmp_path, serialized).map_err(|error| { + format!( + "Failed to write manifest tmp file {}: {}", + tmp_path.display(), + error + ) + })?; + fs::rename(&tmp_path, self.manifest_path(snapshot_id)).map_err(|error| { + format!( + "Failed to commit manifest {}: {}", + self.manifest_path(snapshot_id).display(), + error + ) + })?; + + Ok(()) + } + + /// Delete a snapshot manifest file. Idempotent — a missing file is a + /// no-op. Does **not** touch blobs; the caller is expected to follow + /// up with `gc_unreferenced_blobs()` after a batch of deletions so + /// the pool stays in sync with the remaining timeline entries. + pub fn delete_manifest(&self, snapshot_id: &str) -> Result<(), String> { + let path = self.manifest_path(snapshot_id); + if path.exists() { + fs::remove_file(&path).map_err(|error| { + format!( + "Failed to delete manifest {}: {}", + path.display(), + error + ) + })?; + } + Ok(()) + } + + /// Persist a new `Manifest` as a fresh snapshot and return its + /// metadata. The caller is responsible for splicing the returned + /// [`SnapshotMeta`] into the timeline and writing it back via + /// [`Self::write_timeline`]. + /// + /// `disk_tree_hash` should be the `hash_tree` of the file-tree that + /// this manifest represents, so the post-run "does disk still match + /// this snapshot?" check can be a pure string compare. + pub fn push_snapshot( + &self, + manifest: &Manifest, + disk_tree_hash: String, + ) -> Result { + let id = new_snapshot_id(); + self.write_manifest(&id, manifest)?; + Ok(SnapshotMeta { + id, + created_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), + disk_tree_hash, + }) + } + + /// Atomically write the pre-run pending marker. Called at the moment + /// a new run starts, to capture the disk state that the post-run hook + /// can later compare against and either commit (disk changed) or + /// discard (disk unchanged). + /// + /// Safe to call when a `pending.json` already exists — it overwrites. + /// The caller decides the overwrite policy (session_gateway checks + /// "does pending already exist?" to make the pre-run hook idempotent + /// within a single run while still allowing chained runs to + /// re-snapshot). + pub fn write_pending(&self, pending: &PendingSnapshot) -> Result<(), String> { + fs::create_dir_all(&self.session_dir).map_err(|error| { + format!( + "Failed to create session snapshot dir {}: {}", + self.session_dir.display(), + error + ) + })?; + + let tmp_path = self.pending_tmp_path(); + let serialized = serde_json::to_vec_pretty(pending).map_err(|error| { + format!("Failed to serialize pending snapshot: {}", error) + })?; + fs::write(&tmp_path, serialized).map_err(|error| { + format!( + "Failed to write pending snapshot tmp file {}: {}", + tmp_path.display(), + error + ) + })?; + fs::rename(&tmp_path, self.pending_path()).map_err(|error| { + format!( + "Failed to commit pending snapshot to {}: {}", + self.pending_path().display(), + error + ) + })?; + + Ok(()) + } + + /// Read the pending marker if it exists. Returns `Ok(None)` when + /// there's nothing pending (normal case: no run has started since the + /// last `take_pending` call). + pub fn read_pending(&self) -> Result, String> { + let pending_path = self.pending_path(); + if !pending_path.exists() { + return Ok(None); + } + + let contents = fs::read_to_string(&pending_path).map_err(|error| { + format!( + "Failed to read pending snapshot {}: {}", + pending_path.display(), + error + ) + })?; + + let pending: PendingSnapshot = serde_json::from_str(&contents).map_err(|error| { + format!( + "Failed to parse pending snapshot {}: {}", + pending_path.display(), + error + ) + })?; + + Ok(Some(pending)) + } + + /// Remove the pending marker. Idempotent — does nothing if already + /// absent. Does NOT touch blobs; callers should follow up with + /// `gc_unreferenced_blobs()` to reclaim the pending snapshot's blobs + /// if they're not referenced by `slot.json`. + pub fn clear_pending(&self) -> Result<(), String> { + let pending_path = self.pending_path(); + if pending_path.exists() { + fs::remove_file(&pending_path).map_err(|error| { + format!( + "Failed to remove pending snapshot {}: {}", + pending_path.display(), + error + ) + })?; + } + Ok(()) + } + + /// Materialise the given manifest onto `source_root`: files on disk + /// that are not in the manifest get removed, files missing on disk + /// (or whose sha256 doesn't match) get rewritten from the blob pool, + /// and directories/symlinks are created as needed. + /// + /// This is a pure "make disk match manifest" operation — it does + /// **not** touch the timeline or pending marker. The caller + /// (`undo_commands` / session_gateway) is responsible for updating + /// the timeline cursor after a successful apply. + pub fn apply_manifest( + &self, + manifest: &Manifest, + source_root: &Path, + ) -> Result<(), String> { + if !source_root.exists() { + fs::create_dir_all(source_root).map_err(|error| { + format!( + "Failed to recreate source root {}: {}", + source_root.display(), + error + ) + })?; + } + + // Build set of rel_paths the manifest wants on disk. + let wanted: HashSet<&str> = + manifest.entries.iter().map(|entry| entry.rel_path()).collect(); + + // Walk current disk, delete anything not in `wanted`. Walk files + // first, directories second (bottom-up) so non-empty dir removal + // works. + let mut disk_files = Vec::new(); + let mut disk_dirs = Vec::new(); + let mut disk_symlinks = Vec::new(); + collect_disk_entries( + source_root, + source_root, + &mut disk_files, + &mut disk_dirs, + &mut disk_symlinks, + )?; + + for (rel_path, abs_path) in &disk_symlinks { + if !wanted.contains(rel_path.as_str()) { + let _ = fs::remove_file(abs_path); + } + } + for (rel_path, abs_path) in &disk_files { + if !wanted.contains(rel_path.as_str()) { + fs::remove_file(abs_path).map_err(|error| { + format!( + "Failed to remove {} during snapshot apply: {}", + abs_path.display(), + error + ) + })?; + } + } + // Sort dirs longest-first so we remove children before parents. + disk_dirs.sort_by(|left, right| right.0.len().cmp(&left.0.len())); + for (rel_path, abs_path) in &disk_dirs { + if !wanted.contains(rel_path.as_str()) { + // Only remove if empty after file cleanup. If it's still + // populated (e.g. nested wanted file survived), skip. + let _ = fs::remove_dir(abs_path); + } + } + + // Now materialise the manifest: create dirs, write files, create + // symlinks. We walk `manifest.entries` in pre-order (already + // sorted by rel_path, which is lexicographic and close enough to + // pre-order for parent-before-child — we double-guard by + // `create_dir_all` for files). + for entry in &manifest.entries { + let rel_path = entry.rel_path(); + let abs_path = source_root.join(sanitize_rel_path(rel_path)?); + + match entry { + ManifestEntry::Dir { .. } => { + fs::create_dir_all(&abs_path).map_err(|error| { + format!( + "Failed to create directory {}: {}", + abs_path.display(), + error + ) + })?; + } + ManifestEntry::File { sha256, .. } => { + // Skip rewrite if disk already matches (saves I/O). + if file_matches_hash(&abs_path, sha256).unwrap_or(false) { + continue; + } + + if let Some(parent) = abs_path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "Failed to create parent dir {}: {}", + parent.display(), + error + ) + })?; + } + + let bytes = self.read_blob(sha256)?; + + // Atomic write via tmp + rename. Use a dotfile tmp name + // alongside the target so it shares the same filesystem. + let tmp_path = tmp_sibling(&abs_path); + fs::write(&tmp_path, bytes).map_err(|error| { + format!( + "Failed to write tmp file {}: {}", + tmp_path.display(), + error + ) + })?; + fs::rename(&tmp_path, &abs_path).map_err(|error| { + format!( + "Failed to commit file {}: {}", + abs_path.display(), + error + ) + })?; + } + ManifestEntry::Symlink { target, .. } => { + // Remove any existing entry at that path first so we + // don't clash with an existing regular file. + let _ = fs::remove_file(&abs_path); + if let Some(parent) = abs_path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "Failed to create parent dir {}: {}", + parent.display(), + error + ) + })?; + } + create_symlink(target, &abs_path)?; + } + } + } + + Ok(()) + } + + /// Remove any blob files that are not referenced by: + /// + /// - any manifest in the current timeline, OR + /// - the in-flight pending marker (if any). + /// + /// Also cleans up orphaned `snapshots/*.json` files whose IDs are + /// no longer in the timeline (left behind by a half-completed + /// truncation, retention drop, or crash). + /// + /// Idempotent: safe to call more than once. + pub fn gc_unreferenced_blobs(&self) -> Result<(), String> { + // First, collect every sha256 that is reachable from the live + // timeline + pending marker. This is the "keep set". + let mut referenced: HashSet = HashSet::new(); + + let timeline = self.read_timeline()?; + let alive_ids: HashSet = + timeline.snapshots.iter().map(|s| s.id.clone()).collect(); + + for snapshot in &timeline.snapshots { + // Best-effort: if a manifest is unreadable (e.g. corrupted), + // we log and keep GC running — better to leak blobs than to + // wipe the pool outright. + match self.read_manifest(&snapshot.id) { + Ok(manifest) => { + for entry in &manifest.entries { + if let ManifestEntry::File { sha256, .. } = entry { + referenced.insert(sha256.clone()); + } + } + } + Err(error) => { + eprintln!( + "[cowork] gc: failed to read manifest {} during scan: {}", + snapshot.id, error + ); + } + } + } + + if let Some(pending) = self.read_pending()? { + for entry in &pending.manifest.entries { + if let ManifestEntry::File { sha256, .. } = entry { + referenced.insert(sha256.clone()); + } + } + } + + // Clean up orphan manifest files (IDs not in the timeline). + if let Ok(entries) = fs::read_dir(self.snapshots_dir()) { + for entry in entries.flatten() { + let path = entry.path(); + let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + // Strip `.json` / `.json.tmp` and check membership in + // `alive_ids`. `.tmp` leftovers are always orphans. + if file_name.ends_with(".json.tmp") { + let _ = fs::remove_file(&path); + continue; + } + let Some(id) = file_name.strip_suffix(".json") else { + continue; + }; + if !alive_ids.contains(id) { + let _ = fs::remove_file(&path); + } + } + } + + let blobs_dir = self.blobs_dir(); + if !blobs_dir.exists() { + return Ok(()); + } + + let shard_entries = match fs::read_dir(&blobs_dir) { + Ok(iter) => iter, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(format!( + "Failed to scan blobs dir {}: {}", + blobs_dir.display(), + error + )) + } + }; + + for shard_entry in shard_entries.flatten() { + let shard_path = shard_entry.path(); + if !shard_path.is_dir() { + continue; + } + + let blob_iter = match fs::read_dir(&shard_path) { + Ok(iter) => iter, + Err(_) => continue, + }; + for blob_entry in blob_iter.flatten() { + let blob_path = blob_entry.path(); + let Some(file_name) = blob_path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + // Skip any `.tmp` leftovers from interrupted writes — + // they're orphaned tmp files from a crashed snapshot, not + // valid blobs. Clean them up. + if file_name.ends_with(".tmp") { + let _ = fs::remove_file(&blob_path); + continue; + } + // Strip the compression suffix to recover the canonical + // sha256 key before looking it up in the reference set. + // Legacy (uncompressed) blobs would have no suffix — we + // treat those as orphans and sweep them too, since the + // current code only writes `.zst` files. + let Some(hash) = file_name.strip_suffix(BLOB_SUFFIX) else { + let _ = fs::remove_file(&blob_path); + continue; + }; + if !referenced.contains(hash) { + let _ = fs::remove_file(&blob_path); + } + } + + // Remove now-empty shard dir. + if fs::read_dir(&shard_path) + .map(|mut iter| iter.next().is_none()) + .unwrap_or(false) + { + let _ = fs::remove_dir(&shard_path); + } + } + + Ok(()) + } + + /// Copy a single file's bytes into the blob pool, returning its sha256. + /// If the blob already exists, we skip the write. + /// Hash, compress, and atomically write a file's contents into the + /// CAS pool. Returns the sha256 of the **uncompressed** bytes (the + /// CAS key). If an identical blob already exists, skip the write. + /// + /// Blobs on disk are always zstd-compressed. The compression + /// happens at store time; consumers use [`Self::read_blob`] to get + /// the original bytes back. + fn store_blob(&self, bytes: &[u8]) -> Result { + let hash = sha256_hex(bytes); + let blob_path = self.blob_path_for(&hash); + if blob_path.exists() { + return Ok(hash); + } + + if let Some(parent) = blob_path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "Failed to create blob shard dir {}: {}", + parent.display(), + error + ) + })?; + } + + let compressed = zstd::encode_all(bytes, ZSTD_LEVEL).map_err(|error| { + format!( + "Failed to zstd-compress blob {}: {}", + hash, error + ) + })?; + + // Append `.tmp` to the full filename (not set_extension, which + // would replace `.zst` and lose the suffix info). + let tmp_path = { + let parent = blob_path.parent().unwrap_or_else(|| Path::new(".")); + let file_name = blob_path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| format!("{hash}{BLOB_SUFFIX}")); + parent.join(format!("{file_name}.tmp")) + }; + fs::write(&tmp_path, &compressed).map_err(|error| { + format!( + "Failed to write blob tmp file {}: {}", + tmp_path.display(), + error + ) + })?; + fs::rename(&tmp_path, &blob_path).map_err(|error| { + format!( + "Failed to commit blob {}: {}", + blob_path.display(), + error + ) + })?; + Ok(hash) + } + + /// Load a blob from the CAS pool and decompress it. Returns the + /// original uncompressed bytes. Used by [`Self::apply_manifest`] + /// when materializing a snapshot onto disk. + fn read_blob(&self, hash: &str) -> Result, String> { + let blob_path = self.blob_path_for(hash); + let compressed = fs::read(&blob_path).map_err(|error| { + format!( + "Failed to read blob {}: {}", + blob_path.display(), + error + ) + })?; + zstd::decode_all(compressed.as_slice()).map_err(|error| { + format!( + "Failed to zstd-decompress blob {}: {}", + blob_path.display(), + error + ) + }) + } +} + +// ============================================================================ +// Helpers +// ============================================================================ + +fn walk_source_tree( + root: &Path, + current: &Path, + entries: &mut Vec, + store: &SnapshotStore, + previous_cache: &StatCache, + next_cache: &mut StatCache, +) -> Result<(), String> { + let read_dir = fs::read_dir(current).map_err(|error| { + format!( + "Failed to read directory {} during snapshot: {}", + current.display(), + error + ) + })?; + + for entry_result in read_dir { + let entry = entry_result.map_err(|error| { + format!( + "Failed to iterate {} during snapshot: {}", + current.display(), + error + ) + })?; + let abs_path = entry.path(); + let metadata = fs::symlink_metadata(&abs_path).map_err(|error| { + format!( + "Failed to stat {} during snapshot: {}", + abs_path.display(), + error + ) + })?; + + let rel_path = rel_path_string(root, &abs_path)?; + let file_type = metadata.file_type(); + + if file_type.is_symlink() { + let target = fs::read_link(&abs_path) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + entries.push(ManifestEntry::Symlink { rel_path, target }); + continue; + } + + if file_type.is_dir() { + entries.push(ManifestEntry::Dir { + rel_path: rel_path.clone(), + }); + walk_source_tree(root, &abs_path, entries, store, previous_cache, next_cache)?; + continue; + } + + if file_type.is_file() { + let size = metadata.len(); + let mtime_nanos = metadata + .modified() + .ok() + .and_then(|mtime| mtime.duration_since(UNIX_EPOCH).ok()) + .map(|dur| dur.as_nanos()) + .unwrap_or(0); + + // Stat cache fast path: if the previous scan recorded + // this file with matching (mtime, size), reuse its sha256 + // and skip both the `fs::read` and the SHA-256 computation. + // We still verify the blob is on disk — GC could have + // dropped it — before trusting the cache entry. + let cached = previous_cache.entries.get(&rel_path); + let reuse_hash = match cached { + Some(entry) + if entry.mtime_nanos == mtime_nanos + && entry.size == size + && store.blob_path_for(&entry.sha256).exists() => + { + Some(entry.sha256.clone()) + } + _ => None, + }; + + let sha256 = if let Some(hash) = reuse_hash { + hash + } else { + let bytes = fs::read(&abs_path).map_err(|error| { + format!( + "Failed to read file {} during snapshot: {}", + abs_path.display(), + error + ) + })?; + store.store_blob(&bytes)? + }; + + next_cache.entries.insert( + rel_path.clone(), + StatCacheEntry { + mtime_nanos, + size, + sha256: sha256.clone(), + }, + ); + entries.push(ManifestEntry::File { rel_path, sha256 }); + continue; + } + + // Unknown file type (socket, device, …) — skip and log. We don't + // want to fail the whole snapshot for one weird entry. + eprintln!( + "[cowork] snapshot skipping non-regular entry {} (unknown file type)", + abs_path.display() + ); + } + + Ok(()) +} + +fn collect_disk_entries( + root: &Path, + current: &Path, + files: &mut Vec<(String, PathBuf)>, + dirs: &mut Vec<(String, PathBuf)>, + symlinks: &mut Vec<(String, PathBuf)>, +) -> Result<(), String> { + let read_dir = match fs::read_dir(current) { + Ok(iter) => iter, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(format!( + "Failed to read directory {}: {}", + current.display(), + error + )); + } + }; + + for entry_result in read_dir { + let entry = entry_result.map_err(|error| { + format!( + "Failed to iterate {}: {}", + current.display(), + error + ) + })?; + let abs_path = entry.path(); + let metadata = match fs::symlink_metadata(&abs_path) { + Ok(m) => m, + Err(_) => continue, + }; + let rel_path = rel_path_string(root, &abs_path)?; + let file_type = metadata.file_type(); + + if file_type.is_symlink() { + symlinks.push((rel_path, abs_path)); + } else if file_type.is_dir() { + dirs.push((rel_path.clone(), abs_path.clone())); + collect_disk_entries(root, &abs_path, files, dirs, symlinks)?; + } else if file_type.is_file() { + files.push((rel_path, abs_path)); + } + } + + Ok(()) +} + +fn rel_path_string(root: &Path, abs_path: &Path) -> Result { + let rel = abs_path.strip_prefix(root).map_err(|error| { + format!( + "Path {} is not within root {}: {}", + abs_path.display(), + root.display(), + error + ) + })?; + Ok(rel.to_string_lossy().replace('\\', "/")) +} + +fn sanitize_rel_path(rel_path: &str) -> Result { + // Reject anything that would escape the root. + let path = PathBuf::from(rel_path); + for component in path.components() { + match component { + Component::Normal(_) | Component::CurDir => {} + _ => { + return Err(format!( + "Manifest rel_path {rel_path:?} contains an unsafe component" + )) + } + } + } + Ok(path) +} + +/// Generate a fresh snapshot ID. Format: `{epoch_millis}-{rand_hex}`. +/// The leading timestamp means lexicographic sort matches creation +/// order, which is the property the [`Timeline::snapshots`] list needs +/// when we do GC by "files in `snapshots/` dir not in timeline". +/// The trailing random hex (8 chars of the current-time nanos hash) +/// prevents collisions if two runs finish in the same millisecond. +fn new_snapshot_id() -> String { + let now = Utc::now(); + let millis = now.timestamp_millis(); + let nanos = now.timestamp_subsec_nanos(); + let rand_tag: String = Sha256::digest(nanos.to_le_bytes()) + .iter() + .take(4) + .map(|b| format!("{b:02x}")) + .collect(); + format!("{millis:013}-{rand_tag}") +} + +fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + digest.iter().map(|value| format!("{value:02x}")).collect() +} + +fn file_matches_hash(abs_path: &Path, expected: &str) -> Result { + if !abs_path.exists() { + return Ok(false); + } + let metadata = match fs::symlink_metadata(abs_path) { + Ok(m) => m, + Err(_) => return Ok(false), + }; + if !metadata.is_file() { + return Ok(false); + } + + // Stream-hash so we don't allocate huge Vecs for large files we only + // need to compare. + let mut file = fs::File::open(abs_path).map_err(|error| { + format!("Failed to open {} for hash compare: {}", abs_path.display(), error) + })?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + loop { + let n = file.read(&mut buffer).map_err(|error| { + format!( + "Failed to read {} during hash compare: {}", + abs_path.display(), + error + ) + })?; + if n == 0 { + break; + } + hasher.update(&buffer[..n]); + } + let hash_hex: String = hasher + .finalize() + .iter() + .map(|value| format!("{value:02x}")) + .collect(); + Ok(hash_hex == expected) +} + +fn tmp_sibling(abs_path: &Path) -> PathBuf { + let parent = abs_path.parent().unwrap_or_else(|| Path::new(".")); + let file_name = abs_path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + parent.join(format!(".{file_name}.cowork-tmp")) +} + +#[cfg(unix)] +fn create_symlink(target: &str, link_path: &Path) -> Result<(), String> { + use std::os::unix::fs::symlink; + symlink(target, link_path).map_err(|error| { + format!( + "Failed to create symlink {} → {}: {}", + link_path.display(), + target, + error + ) + }) +} + +#[cfg(windows)] +fn create_symlink(target: &str, link_path: &Path) -> Result<(), String> { + // On Windows we can't know for sure whether the target was a file or + // directory originally. Best effort: try file first, fall back to dir. + use std::os::windows::fs::{symlink_dir, symlink_file}; + if let Err(file_err) = symlink_file(target, link_path) { + if let Err(dir_err) = symlink_dir(target, link_path) { + return Err(format!( + "Failed to create symlink {} → {} (file: {}, dir: {})", + link_path.display(), + target, + file_err, + dir_err + )); + } + } + Ok(()) +} + +#[cfg(not(any(unix, windows)))] +fn create_symlink(_target: &str, _link_path: &Path) -> Result<(), String> { + Err("Symlinks are not supported on this platform".to_string()) +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, + }; + + struct TestDirs { + src: PathBuf, + store: PathBuf, + } + + impl Drop for TestDirs { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.src); + let _ = fs::remove_dir_all(&self.store); + } + } + + fn make_test_dirs(tag: &str) -> TestDirs { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos(); + let src = std::env::temp_dir().join(format!("ii-agent-snapshot-src-{tag}-{unique}")); + let store = std::env::temp_dir().join(format!("ii-agent-snapshot-store-{tag}-{unique}")); + fs::create_dir_all(&src).unwrap(); + fs::create_dir_all(&store).unwrap(); + TestDirs { src, store } + } + + fn write(path: &Path, contents: &[u8]) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, contents).unwrap(); + } + + fn read_all_files_sorted(root: &Path) -> Vec<(String, Vec)> { + let mut out = Vec::new(); + let mut files = Vec::new(); + let mut dirs = Vec::new(); + let mut syms = Vec::new(); + collect_disk_entries(root, root, &mut files, &mut dirs, &mut syms).unwrap(); + for (rel, abs) in files { + out.push((rel, fs::read(abs).unwrap())); + } + out.sort_by(|a, b| a.0.cmp(&b.0)); + out + } + + /// Helper to snapshot current disk and immediately push it onto the + /// timeline. Mirrors what `session_gateway::push_post_run_snapshot` + /// does in production, simplified for tests. + fn snapshot_and_push(store: &SnapshotStore, src: &Path, tag: &str) { + let manifest = store.snapshot(src).unwrap(); + let meta = store.push_snapshot(&manifest, format!("tree-hash-{tag}")).unwrap(); + let mut timeline = store.read_timeline().unwrap(); + timeline.snapshots.push(meta); + timeline.cursor = timeline.snapshots.len() - 1; + store.write_timeline(&timeline).unwrap(); + } + + #[test] + fn snapshot_then_apply_roundtrip() { + let dirs = make_test_dirs("roundtrip"); + let store = SnapshotStore::from_dir(dirs.store.clone()); + + write(&dirs.src.join("a.txt"), b"hello"); + write(&dirs.src.join("sub/b.txt"), b"world"); + write(&dirs.src.join("sub/nested/c.txt"), b"nested"); + + let manifest = store.snapshot(&dirs.src).unwrap(); + let original = read_all_files_sorted(&dirs.src); + + // Mutate disk in various ways: modify, delete, add. + write(&dirs.src.join("a.txt"), b"hello-modified"); + fs::remove_file(dirs.src.join("sub/b.txt")).unwrap(); + write(&dirs.src.join("sub/added.txt"), b"brand new"); + fs::remove_file(dirs.src.join("sub/nested/c.txt")).unwrap(); + fs::remove_dir(dirs.src.join("sub/nested")).unwrap(); + + // apply_manifest restores from the in-memory manifest. + store.apply_manifest(&manifest, &dirs.src).unwrap(); + + let restored = read_all_files_sorted(&dirs.src); + assert_eq!( + restored, original, + "applying the manifest should restore disk byte-for-byte" + ); + } + + #[test] + fn timeline_push_and_navigate() { + let dirs = make_test_dirs("timeline-nav"); + let store = SnapshotStore::from_dir(dirs.store.clone()); + + // Three states, pushed in order: A, B, C. + write(&dirs.src.join("file.txt"), b"state-A"); + snapshot_and_push(&store, &dirs.src, "A"); + let state_a = read_all_files_sorted(&dirs.src); + + write(&dirs.src.join("file.txt"), b"state-B"); + write(&dirs.src.join("new-in-b.txt"), b"only-in-B"); + snapshot_and_push(&store, &dirs.src, "B"); + let state_b = read_all_files_sorted(&dirs.src); + + write(&dirs.src.join("file.txt"), b"state-C"); + fs::remove_file(dirs.src.join("new-in-b.txt")).unwrap(); + write(&dirs.src.join("new-in-c.txt"), b"only-in-C"); + snapshot_and_push(&store, &dirs.src, "C"); + let state_c = read_all_files_sorted(&dirs.src); + + let timeline = store.read_timeline().unwrap(); + assert_eq!(timeline.snapshots.len(), 3); + assert_eq!(timeline.cursor, 2); + assert!(timeline.can_undo()); + assert!(!timeline.can_redo()); + + // Walk back: C -> B -> A via apply_manifest + cursor updates. + let manifest_b = store.read_manifest(&timeline.snapshots[1].id).unwrap(); + store.apply_manifest(&manifest_b, &dirs.src).unwrap(); + assert_eq!(read_all_files_sorted(&dirs.src), state_b); + + let manifest_a = store.read_manifest(&timeline.snapshots[0].id).unwrap(); + store.apply_manifest(&manifest_a, &dirs.src).unwrap(); + assert_eq!(read_all_files_sorted(&dirs.src), state_a); + + // Walk forward: A -> C. + let manifest_c = store.read_manifest(&timeline.snapshots[2].id).unwrap(); + store.apply_manifest(&manifest_c, &dirs.src).unwrap(); + assert_eq!(read_all_files_sorted(&dirs.src), state_c); + } + + #[test] + fn gc_preserves_all_timeline_blobs() { + let dirs = make_test_dirs("gc-timeline"); + let store = SnapshotStore::from_dir(dirs.store.clone()); + + // Two distinct states with different file contents. + write(&dirs.src.join("a.txt"), b"v1"); + snapshot_and_push(&store, &dirs.src, "v1"); + + write(&dirs.src.join("a.txt"), b"v2"); + snapshot_and_push(&store, &dirs.src, "v2"); + + store.gc_unreferenced_blobs().unwrap(); + + // Both blobs referenced by the two snapshots must survive GC. + let v1_hash = sha256_hex(b"v1"); + let v2_hash = sha256_hex(b"v2"); + assert!(store.blob_path_for(&v1_hash).exists(), "v1 blob must survive"); + assert!(store.blob_path_for(&v2_hash).exists(), "v2 blob must survive"); + } + + #[test] + fn gc_drops_orphaned_manifest_files() { + let dirs = make_test_dirs("gc-orphan"); + let store = SnapshotStore::from_dir(dirs.store.clone()); + + write(&dirs.src.join("a.txt"), b"only"); + snapshot_and_push(&store, &dirs.src, "only"); + + // Write a stray manifest file that isn't in the timeline. + let orphan_id = "00000000000-abcd"; + let manifest = store.snapshot(&dirs.src).unwrap(); + store.write_manifest(orphan_id, &manifest).unwrap(); + assert!(store.manifest_path(orphan_id).exists()); + + store.gc_unreferenced_blobs().unwrap(); + assert!( + !store.manifest_path(orphan_id).exists(), + "orphaned manifest file should be deleted by GC" + ); + } + + #[test] + fn gc_drops_blobs_no_longer_in_timeline() { + let dirs = make_test_dirs("gc-drop"); + let store = SnapshotStore::from_dir(dirs.store.clone()); + + write(&dirs.src.join("keep.txt"), b"keep"); + write(&dirs.src.join("drop.txt"), b"drop-me"); + + // First snapshot has both files. + snapshot_and_push(&store, &dirs.src, "both"); + + // Remove drop.txt from disk and take a fresh snapshot. If we + // simulate the retention scenario by discarding the first + // snapshot and keeping only the second, drop-me's blob becomes + // orphaned. + fs::remove_file(dirs.src.join("drop.txt")).unwrap(); + let manifest = store.snapshot(&dirs.src).unwrap(); + let meta = store.push_snapshot(&manifest, "after".to_string()).unwrap(); + + // Simulate retention: drop the oldest snapshot, keep only the new one. + let mut timeline = store.read_timeline().unwrap(); + let dropped = timeline.snapshots.remove(0); + store.delete_manifest(&dropped.id).unwrap(); + timeline.snapshots.push(meta); + timeline.cursor = 0; + store.write_timeline(&timeline).unwrap(); + + store.gc_unreferenced_blobs().unwrap(); + + let drop_hash = sha256_hex(b"drop-me"); + assert!( + !store.blob_path_for(&drop_hash).exists(), + "orphaned blob should be GC'd" + ); + let keep_hash = sha256_hex(b"keep"); + assert!( + store.blob_path_for(&keep_hash).exists(), + "blob still referenced must survive" + ); + } + + #[test] + fn cas_dedup_identical_content() { + let dirs = make_test_dirs("dedup"); + let store = SnapshotStore::from_dir(dirs.store.clone()); + + write(&dirs.src.join("one.txt"), b"same"); + write(&dirs.src.join("two.txt"), b"same"); + write(&dirs.src.join("three.txt"), b"same"); + + snapshot_and_push(&store, &dirs.src, "same"); + + let hash = sha256_hex(b"same"); + let blob_path = store.blob_path_for(&hash); + assert!(blob_path.exists(), "single shared blob should exist"); + + // Count blobs in the shard directory for this hash prefix. + let shard_dir = store.blobs_dir().join(&hash[..2]); + let count = fs::read_dir(&shard_dir).unwrap().count(); + assert_eq!( + count, 1, + "CAS should dedupe identical file contents into a single blob" + ); + } + + #[test] + fn read_timeline_returns_empty_when_missing() { + let dirs = make_test_dirs("missing"); + let store = SnapshotStore::from_dir(dirs.store.clone()); + let timeline = store.read_timeline().unwrap(); + assert!(timeline.snapshots.is_empty()); + assert_eq!(timeline.cursor, 0); + assert!(!timeline.can_undo()); + assert!(!timeline.can_redo()); + } + + #[test] + fn timeline_clamps_cursor_on_load() { + let dirs = make_test_dirs("clamp"); + let store = SnapshotStore::from_dir(dirs.store.clone()); + + // Write a corrupted timeline with cursor out of bounds. + write(&dirs.src.join("a.txt"), b"a"); + snapshot_and_push(&store, &dirs.src, "a"); + + let corrupted = Timeline { + snapshots: store.read_timeline().unwrap().snapshots, + cursor: 999, + }; + store.write_timeline(&corrupted).unwrap(); + + // Next read should clamp to last valid index. + let loaded = store.read_timeline().unwrap(); + assert_eq!(loaded.cursor, loaded.snapshots.len() - 1); + } + + #[test] + fn pending_write_read_clear_roundtrip() { + let dirs = make_test_dirs("pending"); + let store = SnapshotStore::from_dir(dirs.store.clone()); + + write(&dirs.src.join("a.txt"), b"pending-content"); + let manifest = store.snapshot(&dirs.src).unwrap(); + let pending = PendingSnapshot { + pre_run_tree_hash: "deadbeef".to_string(), + manifest, + }; + + assert!(store.read_pending().unwrap().is_none()); + + store.write_pending(&pending).unwrap(); + let round_tripped = store.read_pending().unwrap().unwrap(); + assert_eq!(round_tripped.pre_run_tree_hash, "deadbeef"); + + store.clear_pending().unwrap(); + assert!(store.read_pending().unwrap().is_none()); + } + + #[test] + fn gc_preserves_pending_blobs() { + let dirs = make_test_dirs("pending-gc"); + let store = SnapshotStore::from_dir(dirs.store.clone()); + + write(&dirs.src.join("live.txt"), b"lives-in-pending"); + let manifest = store.snapshot(&dirs.src).unwrap(); + let pending = PendingSnapshot { + pre_run_tree_hash: "hash".to_string(), + manifest, + }; + store.write_pending(&pending).unwrap(); + + // No timeline entries, just pending. GC must NOT remove the blob. + store.gc_unreferenced_blobs().unwrap(); + + let hash = sha256_hex(b"lives-in-pending"); + let blob_path = store.blob_path_for(&hash); + assert!( + blob_path.exists(), + "pending-referenced blob must survive GC: {}", + blob_path.display() + ); + } + + #[test] + fn sanitize_rel_path_rejects_traversal() { + assert!(sanitize_rel_path("../escape").is_err()); + assert!(sanitize_rel_path("a/../../b").is_err()); + assert!(sanitize_rel_path("/abs/path").is_err()); + assert!(sanitize_rel_path("ok/sub/file.txt").is_ok()); + } + + // ---- Stat cache ---- + + #[test] + fn stat_cache_reused_on_unchanged_files() { + let dirs = make_test_dirs("stat-cache"); + let store = SnapshotStore::from_dir(dirs.store.clone()); + + write(&dirs.src.join("a.txt"), b"original content"); + write(&dirs.src.join("b.txt"), b"other content"); + + // First snapshot populates the cache with both entries. + let _ = store.snapshot(&dirs.src).unwrap(); + let cache_v1 = store.read_stat_cache(); + assert!(cache_v1.entries.contains_key("a.txt")); + assert!(cache_v1.entries.contains_key("b.txt")); + let a_hash_v1 = cache_v1.entries["a.txt"].sha256.clone(); + + // Delete the blob manually to prove the second snapshot would + // detect a cache hit BUT fall through to re-hash because the + // blob is missing. After the call the blob should be back. + let blob_path = store.blob_path_for(&a_hash_v1); + assert!(blob_path.exists()); + fs::remove_file(&blob_path).unwrap(); + assert!(!blob_path.exists()); + + // Re-snapshot without modifying any file. The cache entries + // should be preserved (same sha256), and the missing blob + // should be repopulated by the fall-through path. + let _ = store.snapshot(&dirs.src).unwrap(); + let cache_v2 = store.read_stat_cache(); + assert_eq!(cache_v2.entries["a.txt"].sha256, a_hash_v1); + assert!( + blob_path.exists(), + "missing blob must be recreated on cache fall-through" + ); + } + + #[test] + fn stat_cache_rehashes_when_mtime_or_size_changes() { + let dirs = make_test_dirs("stat-invalidate"); + let store = SnapshotStore::from_dir(dirs.store.clone()); + + write(&dirs.src.join("f.txt"), b"v1"); + let _ = store.snapshot(&dirs.src).unwrap(); + let hash_v1 = store.read_stat_cache().entries["f.txt"].sha256.clone(); + + // Change content (different size and — typically — different + // mtime). The cache key should invalidate and the new blob + // should have a different hash. + // Brief sleep ensures mtime changes on filesystems with 1s + // resolution — not strictly needed if we also change size. + std::thread::sleep(std::time::Duration::from_millis(15)); + write(&dirs.src.join("f.txt"), b"v2-longer-content"); + let _ = store.snapshot(&dirs.src).unwrap(); + let hash_v2 = store.read_stat_cache().entries["f.txt"].sha256.clone(); + + assert_ne!( + hash_v1, hash_v2, + "stat cache failed to invalidate after content change" + ); + } + + // ---- Zstd compression ---- + + #[test] + fn blobs_are_stored_compressed() { + let dirs = make_test_dirs("zstd"); + let store = SnapshotStore::from_dir(dirs.store.clone()); + + // Highly compressible content: repeating pattern. + let payload = "hello world ".repeat(1000); + write(&dirs.src.join("compressible.txt"), payload.as_bytes()); + let _ = store.snapshot(&dirs.src).unwrap(); + + let hash = sha256_hex(payload.as_bytes()); + let blob_path = store.blob_path_for(&hash); + assert!(blob_path.exists()); + + let on_disk = fs::metadata(&blob_path).unwrap().len() as usize; + assert!( + on_disk < payload.len() / 2, + "compressed blob ({on_disk} B) should be much smaller than \ + raw ({raw} B)", + raw = payload.len() + ); + + // Decompress and verify round-trip. + let round_tripped = store.read_blob(&hash).unwrap(); + assert_eq!(round_tripped, payload.as_bytes()); + } + + #[test] + fn apply_manifest_round_trip_with_compression() { + // End-to-end: snapshot → mutate disk → apply_manifest → verify + // the original bytes come back even though everything was + // zstd-compressed in the blob pool. + let dirs = make_test_dirs("zstd-roundtrip"); + let store = SnapshotStore::from_dir(dirs.store.clone()); + + let original = b"the quick brown fox jumps over the lazy dog".repeat(100); + write(&dirs.src.join("doc.txt"), &original); + let manifest = store.snapshot(&dirs.src).unwrap(); + + // Corrupt the file on disk. + write(&dirs.src.join("doc.txt"), b"corrupted"); + + // Restore. + store.apply_manifest(&manifest, &dirs.src).unwrap(); + + let restored = fs::read(dirs.src.join("doc.txt")).unwrap(); + assert_eq!( + restored, original, + "zstd decompress + restore must produce byte-exact original" + ); + } + + #[test] + fn blob_filename_uses_zst_suffix() { + let dirs = make_test_dirs("suffix"); + let store = SnapshotStore::from_dir(dirs.store.clone()); + + write(&dirs.src.join("x.txt"), b"any content"); + let _ = store.snapshot(&dirs.src).unwrap(); + + let hash = sha256_hex(b"any content"); + let blob_path = store.blob_path_for(&hash); + let file_name = blob_path + .file_name() + .unwrap() + .to_string_lossy() + .to_string(); + assert!( + file_name.ends_with(".zst"), + "blob filename should end with .zst, got {file_name}" + ); + } +} diff --git a/frontend/src-tauri/src/cowork/intelligent_folder/undo_commands.rs b/frontend/src-tauri/src/cowork/intelligent_folder/undo_commands.rs new file mode 100644 index 000000000..26d8fbd79 --- /dev/null +++ b/frontend/src-tauri/src/cowork/intelligent_folder/undo_commands.rs @@ -0,0 +1,142 @@ +//! Tauri commands for cowork Intelligent Folder undo / redo navigation. +//! +//! The cowork folder session keeps a **timeline** of snapshots (see +//! [`crate::cowork::intelligent_folder::snapshot_store`]) with a `cursor` +//! pointing at whichever snapshot is currently materialised on disk. +//! Undo and Redo simply move the cursor by ±1 and re-apply the +//! corresponding manifest: +//! +//! ```text +//! [snap0] [snap1] [snap2] [snap3] +//! ^ cursor here +//! --> Undo --> cursor = 1, disk = snap1 +//! --> Redo --> cursor = 3, disk = snap3 +//! ``` +//! +//! After a successful swap we refresh the session's UI-facing +//! `undo_state` hint and `result_tree` so the frontend can render the +//! button counter and tree view without a follow-up fetch. + +use super::sessions::{ + self, sync_result_tree_from_disk, CoworkChatSessionDetail, FolderUndoState, +}; +use super::snapshot_store::{SnapshotStore, Timeline}; +use crate::cowork::time_utils::now_iso; +use std::path::Path; +use tauri::AppHandle; + +#[tauri::command] +pub fn undo_cowork_folder( + app: AppHandle, + session_id: String, +) -> Result { + navigate_timeline(&app, &session_id, Direction::Backward) +} + +#[tauri::command] +pub fn redo_cowork_folder( + app: AppHandle, + session_id: String, +) -> Result { + navigate_timeline(&app, &session_id, Direction::Forward) +} + +#[derive(Clone, Copy)] +enum Direction { + /// Move cursor back by 1 (Undo). + Backward, + /// Move cursor forward by 1 (Redo). + Forward, +} + +impl Direction { + fn verb(self) -> &'static str { + match self { + Direction::Backward => "undo", + Direction::Forward => "redo", + } + } + + /// Compute the target cursor given the current timeline, or return + /// an error if moving in that direction isn't possible. + fn target_cursor(self, timeline: &Timeline) -> Result { + if timeline.snapshots.is_empty() { + return Err(format!( + "Cannot {}: no snapshots have been captured yet", + self.verb() + )); + } + match self { + Direction::Backward => { + if !timeline.can_undo() { + return Err("Cannot undo: already at the oldest snapshot".to_string()); + } + Ok(timeline.cursor - 1) + } + Direction::Forward => { + if !timeline.can_redo() { + return Err("Cannot redo: already at the newest snapshot".to_string()); + } + Ok(timeline.cursor + 1) + } + } + } +} + +fn navigate_timeline( + app: &AppHandle, + session_id: &str, + direction: Direction, +) -> Result { + let mut session = sessions::get_folder_session(app.clone(), session_id.to_string())?; + + let store = SnapshotStore::for_session(app, session_id)?; + let mut timeline = store.read_timeline()?; + + let target_cursor = direction.target_cursor(&timeline)?; + let target_meta = timeline.snapshots[target_cursor].clone(); + + // Materialise the target manifest onto disk. + let manifest = store.read_manifest(&target_meta.id).map_err(|error| { + format!( + "Cowork {}: failed to read manifest {}: {}", + direction.verb(), + target_meta.id, + error + ) + })?; + let source_root = Path::new(&session.folder_tree_pair.source_root); + store.apply_manifest(&manifest, source_root).map_err(|error| { + format!( + "Cowork {}: failed to apply manifest {}: {}", + direction.verb(), + target_meta.id, + error + ) + })?; + + // Advance cursor and persist the updated timeline. GC is a best-effort + // cleanup — not fatal if it fails. + timeline.cursor = target_cursor; + store.write_timeline(&timeline)?; + if let Err(error) = store.gc_unreferenced_blobs() { + eprintln!( + "[cowork] {}: gc_unreferenced_blobs failed: {}", + direction.verb(), + error + ); + } + + // Refresh UI-facing state on the session hint + the tree viewer. + sync_result_tree_from_disk(&mut session)?; + session.undo_state = FolderUndoState { + can_undo: timeline.can_undo(), + can_redo: timeline.can_redo(), + current: timeline.display_position().0, + total: timeline.display_position().1, + }; + session.base.updated_at = now_iso(); + + let persisted = sessions::update_folder_session(app.clone(), session)?; + Ok(persisted) +} diff --git a/frontend/src-tauri/src/cowork/mod.rs b/frontend/src-tauri/src/cowork/mod.rs new file mode 100644 index 000000000..f85000a1c --- /dev/null +++ b/frontend/src-tauri/src/cowork/mod.rs @@ -0,0 +1,14 @@ +pub mod agent_presets; +pub mod agent_remote; +pub mod bootstrap; +pub mod chat; +pub mod chat_commands; +pub mod desktop_runtime; +pub mod desktop_skills; +pub mod desktop_tools; +pub mod homepage; +pub mod intelligent_folder; +pub mod runtime; +pub mod session_gateway; +pub mod string_utils; +pub mod time_utils; diff --git a/frontend/src-tauri/src/cowork/runtime.rs b/frontend/src-tauri/src/cowork/runtime.rs new file mode 100644 index 000000000..dd645303d --- /dev/null +++ b/frontend/src-tauri/src/cowork/runtime.rs @@ -0,0 +1,61 @@ +use crate::cowork::agent_remote::{auth::RemoteAuthState, service as remote_service}; +use crate::cowork::chat::{ + CoworkAgentRuntimeKind, CoworkChatFile, CoworkChatMessage, CoworkChatRunStatus, + CoworkChatRuntimeEvent, CoworkChatSendMessageRequest, CoworkChatSendMessageResponse, +}; +use futures_util::FutureExt; +use std::any::Any; +use std::future::Future; +use tauri::{AppHandle, State}; + +pub struct CoworkRuntimeSessionSnapshot { + pub runtime_kind: CoworkAgentRuntimeKind, + pub runtime_session_id: String, + pub updated_at: String, + pub messages: Vec, + pub files: Vec, + pub run_status: CoworkChatRunStatus, + pub runtime_events: Vec, +} + +pub(crate) async fn guard_async_command(label: &str, future: F) -> Result +where + F: Future>, +{ + match std::panic::AssertUnwindSafe(future).catch_unwind().await { + Ok(result) => result, + Err(payload) => Err(format!( + "{label} panicked: {}", + panic_payload_message(payload.as_ref()) + )), + } +} + +fn panic_payload_message(payload: &(dyn Any + Send)) -> String { + if let Some(message) = payload.downcast_ref::<&str>() { + (*message).to_string() + } else if let Some(message) = payload.downcast_ref::() { + message.clone() + } else { + "non-string panic payload".to_string() + } +} + +#[tauri::command] +pub async fn send_cowork_chat_message( + app: AppHandle, + remote_auth_state: State<'_, RemoteAuthState>, + request: CoworkChatSendMessageRequest, +) -> Result { + guard_async_command("send_cowork_chat_message", async move { + match request.requested_runtime_kind() { + CoworkAgentRuntimeKind::Remote => { + remote_service::send_remote_chat_message(app, remote_auth_state, request).await + } + CoworkAgentRuntimeKind::Local => { + Err("Cowork local runtime is not implemented yet.".to_string()) + } + } + }) + .await +} diff --git a/frontend/src-tauri/src/cowork/session_gateway.rs b/frontend/src-tauri/src/cowork/session_gateway.rs new file mode 100644 index 000000000..e9af0bd61 --- /dev/null +++ b/frontend/src-tauri/src/cowork/session_gateway.rs @@ -0,0 +1,851 @@ +use crate::cowork::chat::{ + emit_cowork_stream_event, CoworkChatEvent, CoworkChatFile, CoworkChatFilesEvent, + CoworkChatMessage, CoworkChatMessageEvent, CoworkChatMessageRole, CoworkChatRunStatus, + CoworkChatRuntimeEvent, CoworkChatScope, CoworkChatSendMessageRequest, + CoworkChatSendMessageResponse, CoworkChatSessionDetail, CoworkChatSessionEvent, + CoworkChatSessionSummary, CoworkChatStatusEvent, +}; +use crate::cowork::homepage::chat_sessions as homepage_sessions; +use crate::cowork::intelligent_folder::chat_prompt as folder_chat_prompt; +use crate::cowork::intelligent_folder::file_tree; +use crate::cowork::intelligent_folder::sessions::{ + self as folder_sessions, FolderUndoState, +}; +use crate::cowork::intelligent_folder::snapshot_store::{ + PendingSnapshot, SnapshotStore, MAX_TIMELINE_LEN, +}; +use crate::cowork::runtime::CoworkRuntimeSessionSnapshot; +use crate::cowork::time_utils::{generate_message_id, now_iso}; +use tauri::AppHandle; + +#[derive(Clone)] +pub enum LocalCoworkSession { + Homepage(CoworkChatSessionDetail), + Folder(folder_sessions::CoworkChatSessionDetail), +} + +impl LocalCoworkSession { + pub fn base(&self) -> &CoworkChatSessionDetail { + match self { + Self::Homepage(session) => session, + Self::Folder(session) => &session.base, + } + } + + pub fn base_mut(&mut self) -> &mut CoworkChatSessionDetail { + match self { + Self::Homepage(session) => session, + Self::Folder(session) => &mut session.base, + } + } +} + +trait FeatureSessionStore { + fn load(&self, app: &AppHandle, session_id: &str) -> Result; + + fn load_or_create( + &self, + app: &AppHandle, + request: &CoworkChatSendMessageRequest, + ) -> Result<(LocalCoworkSession, bool), String>; + + fn save( + &self, + app: &AppHandle, + session: LocalCoworkSession, + ) -> Result; +} + +struct HomepageSessionStore; +struct FolderSessionStore; + +static HOMEPAGE_SESSION_STORE: HomepageSessionStore = HomepageSessionStore; +static FOLDER_SESSION_STORE: FolderSessionStore = FolderSessionStore; + +impl FeatureSessionStore for HomepageSessionStore { + fn load(&self, app: &AppHandle, session_id: &str) -> Result { + homepage_sessions::get_homepage_chat_session(app.clone(), session_id.to_string()) + .map(LocalCoworkSession::Homepage) + } + + fn load_or_create( + &self, + app: &AppHandle, + request: &CoworkChatSendMessageRequest, + ) -> Result<(LocalCoworkSession, bool), String> { + if let Some(session_id) = request.session_id.as_ref() { + let session = + homepage_sessions::get_homepage_chat_session(app.clone(), session_id.clone())?; + Ok((LocalCoworkSession::Homepage(session), false)) + } else { + let title = build_homepage_session_title(&request.content); + let session = homepage_sessions::create_homepage_chat_session(app.clone(), title)?; + Ok((LocalCoworkSession::Homepage(session), true)) + } + } + + fn save( + &self, + app: &AppHandle, + session: LocalCoworkSession, + ) -> Result { + let LocalCoworkSession::Homepage(detail) = session else { + return Err("Homepage session store received a non-homepage session".to_string()); + }; + + homepage_sessions::update_homepage_chat_session(app.clone(), detail) + .map(LocalCoworkSession::Homepage) + } +} + +impl FeatureSessionStore for FolderSessionStore { + fn load(&self, app: &AppHandle, session_id: &str) -> Result { + folder_sessions::get_folder_session(app.clone(), session_id.to_string()) + .map(LocalCoworkSession::Folder) + } + + fn load_or_create( + &self, + app: &AppHandle, + request: &CoworkChatSendMessageRequest, + ) -> Result<(LocalCoworkSession, bool), String> { + let session_id = request + .session_id + .as_ref() + .ok_or_else(|| "Folder cowork session_id is required".to_string())?; + let session = folder_sessions::get_folder_session(app.clone(), session_id.clone())?; + Ok((LocalCoworkSession::Folder(session), false)) + } + + fn save( + &self, + app: &AppHandle, + session: LocalCoworkSession, + ) -> Result { + let LocalCoworkSession::Folder(detail) = session else { + return Err("Folder session store received a non-folder session".to_string()); + }; + + folder_sessions::update_folder_session(app.clone(), detail) + .map(LocalCoworkSession::Folder) + } +} + +pub fn load_or_create_local_session( + app: &AppHandle, + request: &CoworkChatSendMessageRequest, +) -> Result<(LocalCoworkSession, bool), String> { + let (mut session, is_new) = + resolve_store_for_scope(request.scope).load_or_create(app, request)?; + normalize_local_session_runtime(&mut session); + Ok((session, is_new)) +} + +pub fn persist_local_session( + app: &AppHandle, + mut session: LocalCoworkSession, +) -> Result { + normalize_local_session_runtime(&mut session); + // Intelligent Folder snapshot timeline bookkeeping: at run start + // capture a pending pre-run snapshot; at run end push a new timeline + // entry (or discard the pending) based on whether disk actually + // changed. Best-effort — failures are logged and never block the + // persistence path. + maintain_folder_timeline(app, &mut session); + let mut persisted = resolve_store_for_session(&session).save(app, session)?; + normalize_local_session_runtime(&mut persisted); + Ok(persisted) +} + +pub fn load_local_session( + app: &AppHandle, + scope: CoworkChatScope, + session_id: &str, +) -> Result { + let mut session = resolve_store_for_scope(scope).load(app, session_id)?; + normalize_local_session_runtime(&mut session); + Ok(session) +} + +pub fn build_send_response( + session: &LocalCoworkSession, + include_session_created: bool, +) -> CoworkChatSendMessageResponse { + let base = session.base(); + let mut events = Vec::new(); + + if include_session_created { + events.push(build_session_created_event(base)); + } + + events.push(build_session_updated_event(base)); + events.push(build_status_updated_event( + base.scope, + base.id.clone(), + base.run_status, + )); + + CoworkChatSendMessageResponse { + session_id: base.id.clone(), + events, + } +} + +pub fn persist_runtime_error( + app: &AppHandle, + mut session: LocalCoworkSession, + error_message: String, +) -> Result { + let base = session.base_mut(); + + base.messages.push(build_local_message( + CoworkChatMessageRole::Assistant, + error_message, + false, + None, + )); + base.updated_at = now_iso(); + base.preview = build_preview_from_messages(&base.messages); + base.message_count = base.messages.len(); + base.run_status = CoworkChatRunStatus::Stopped; + + sync_folder_result_tree(&mut session)?; + persist_local_session(app, session) +} + +pub fn persist_stream_status( + app: &AppHandle, + scope: CoworkChatScope, + session_id: &str, + status: CoworkChatRunStatus, +) -> Result<(), String> { + let mut session = load_local_session(app, scope, session_id)?; + let base = session.base_mut(); + + base.run_status = status; + base.updated_at = now_iso(); + persist_local_session(app, session).map(|_| ()) +} + +pub fn persist_stream_runtime_event( + app: &AppHandle, + event: &CoworkChatRuntimeEvent, + status: Option, +) -> Result<(), String> { + let mut session = load_local_session(app, event.scope, &event.session_id)?; + let base = session.base_mut(); + + if !base + .runtime_events + .iter() + .any(|current| is_same_runtime_event(current, event)) + { + base.runtime_events.push(event.clone()); + sort_runtime_events(&mut base.runtime_events); + } + + base.updated_at = event.emitted_at.clone(); + if let Some(next_status) = status { + base.run_status = next_status; + } + + persist_local_session(app, session).map(|_| ()) +} + +pub fn apply_runtime_session_snapshot( + session: &mut LocalCoworkSession, + runtime_snapshot: CoworkRuntimeSessionSnapshot, +) { + let base = session.base_mut(); + + base.runtime_kind = Some(runtime_snapshot.runtime_kind); + base.runtime_session_id = Some(runtime_snapshot.runtime_session_id); + base.messages = runtime_snapshot.messages; + base.message_count = base.messages.len(); + base.files = runtime_snapshot.files; + base.runtime_events = + merge_runtime_events(base.runtime_events.clone(), runtime_snapshot.runtime_events); + base.updated_at = runtime_snapshot.updated_at; + base.preview = build_preview_from_messages(&base.messages); + base.run_status = runtime_snapshot.run_status; +} + +pub fn clear_runtime_binding(session: &mut LocalCoworkSession) { + let base = session.base_mut(); + base.runtime_kind = None; + base.runtime_session_id = None; +} + +pub fn sync_folder_result_tree(session: &mut LocalCoworkSession) -> Result<(), String> { + match session { + LocalCoworkSession::Homepage(_) => Ok(()), + LocalCoworkSession::Folder(detail) => { + folder_sessions::sync_result_tree_from_disk(detail) + } + } +} + +/// Intelligent Folder snapshot timeline bookkeeping. Runs on every +/// [`persist_local_session`] call and decides whether to (a) capture a +/// fresh pre-run snapshot, (b) push an existing pending one into the +/// session's timeline + refresh the UI hint, or (c) do nothing. +/// +/// The decision is driven entirely by `session.run_status` and the +/// presence/absence of `pending.json` on disk — no other code needs to +/// call into the snapshot store for pre/post-run tracking. Intentionally +/// never fails: any error is logged and the session is persisted as-is +/// (a broken snapshot path must not break the user's chat flow). +/// +/// State transitions (only active on [`LocalCoworkSession::Folder`]): +/// +/// | run_status | pending.json? | action | +/// |-------------------------------|---------------|------------------------------| +/// | `Thinking` / `Waiting` | absent | write fresh pending snapshot | +/// | `Thinking` / `Waiting` | present | no-op | +/// | `Completed` / `Stopped` / `Idle` | present | compare, push or discard | +/// | `Completed` / `Stopped` / `Idle` | absent | just refresh undo_state hint | +fn maintain_folder_timeline(app: &AppHandle, session: &mut LocalCoworkSession) { + let folder_detail = match session { + LocalCoworkSession::Folder(detail) => detail, + LocalCoworkSession::Homepage(_) => return, + }; + + let session_id = folder_detail.base.id.clone(); + let source_root = folder_detail.folder_tree_pair.source_root.clone(); + + let store = match SnapshotStore::for_session(app, &session_id) { + Ok(store) => store, + Err(error) => { + eprintln!( + "[cowork] timeline: store init failed for session {}: {}", + session_id, error + ); + return; + } + }; + + let is_run_active = matches!( + folder_detail.base.run_status, + CoworkChatRunStatus::Thinking | CoworkChatRunStatus::WaitingForInput + ); + + if is_run_active { + capture_pre_run_snapshot_if_absent(&store, &session_id, &source_root); + // Run is still in-flight — don't touch the timeline or undo_state + // hint yet. The final post-run persist_local_session call will + // sync everything. + return; + } + + // run_status is terminal (Idle / Completed / Stopped). If there's a + // pending marker, this is the moment to compare it against current + // disk and either push a new timeline entry (disk changed) or drop + // it (disk unchanged). + commit_or_discard_pending(&store, &session_id, &source_root, folder_detail); + + // Regardless of whether anything got committed, refresh the + // UI-facing hint from the on-disk timeline — covers the "no pending, + // just refresh" case too so a session loaded after a restart shows + // the correct Undo/Redo buttons. + refresh_undo_state_hint(&store, &session_id, folder_detail); +} + +fn capture_pre_run_snapshot_if_absent( + store: &SnapshotStore, + session_id: &str, + source_root: &str, +) { + match store.read_pending() { + Ok(Some(_)) => { + // Already captured for this run — do not overwrite, otherwise + // mid-run status updates would keep rewriting pending against + // a disk that the agent is already modifying. + } + Ok(None) => { + let pre_run_tree = match file_tree::read_path_tree(source_root.to_string(), None) { + Ok(tree) => tree, + Err(error) => { + eprintln!( + "[cowork] timeline: pre-run tree scan failed for session {}: {}", + session_id, error + ); + return; + } + }; + let pre_run_tree_hash = match folder_sessions::hash_tree(&pre_run_tree) { + Ok(hash) => hash, + Err(error) => { + eprintln!( + "[cowork] timeline: pre-run hash failed for session {}: {}", + session_id, error + ); + return; + } + }; + let manifest = match store.snapshot(std::path::Path::new(source_root)) { + Ok(manifest) => manifest, + Err(error) => { + eprintln!( + "[cowork] timeline: pre-run snapshot failed for session {}: {}", + session_id, error + ); + return; + } + }; + let pending = PendingSnapshot { + pre_run_tree_hash, + manifest, + }; + if let Err(error) = store.write_pending(&pending) { + eprintln!( + "[cowork] timeline: write_pending failed for session {}: {}", + session_id, error + ); + } + } + Err(error) => { + eprintln!( + "[cowork] timeline: read_pending failed for session {}: {}", + session_id, error + ); + } + } +} + +fn commit_or_discard_pending( + store: &SnapshotStore, + session_id: &str, + source_root: &str, + _folder_detail: &mut folder_sessions::CoworkChatSessionDetail, +) { + let pending = match store.read_pending() { + Ok(Some(pending)) => pending, + Ok(None) => return, + Err(error) => { + eprintln!( + "[cowork] timeline: read_pending failed for session {}: {}", + session_id, error + ); + return; + } + }; + + let post_run_tree = match file_tree::read_path_tree(source_root.to_string(), None) { + Ok(tree) => tree, + Err(error) => { + eprintln!( + "[cowork] timeline: post-run tree scan failed for session {}: {}", + session_id, error + ); + let _ = store.clear_pending(); + let _ = store.gc_unreferenced_blobs(); + return; + } + }; + let post_run_hash = match folder_sessions::hash_tree(&post_run_tree) { + Ok(hash) => hash, + Err(error) => { + eprintln!( + "[cowork] timeline: post-run hash failed for session {}: {}", + session_id, error + ); + let _ = store.clear_pending(); + let _ = store.gc_unreferenced_blobs(); + return; + } + }; + + if pending.pre_run_tree_hash == post_run_hash { + // Disk didn't change → discard pending, leave timeline untouched. + let _ = store.clear_pending(); + let _ = store.gc_unreferenced_blobs(); + return; + } + + // Disk DID change → push a new entry into the timeline. + if let Err(error) = push_post_run_snapshot( + store, + session_id, + std::path::Path::new(source_root), + &pending, + post_run_hash, + ) { + eprintln!( + "[cowork] timeline: push_post_run_snapshot failed for session {}: {}", + session_id, error + ); + } + let _ = store.clear_pending(); + let _ = store.gc_unreferenced_blobs(); +} + +/// Commit a change into the timeline. This is the heart of the +/// `/snapshot history/` design: it handles seeding an empty timeline, +/// rebasing when `timeline[cursor]` drifted out of sync with disk, +/// truncating future branches when the user was detached, pushing the +/// new state, and enforcing the retention cap. +/// +/// **Invariant maintained**: after this function returns Ok, the +/// timeline has a `cursor` pointing at a snapshot whose +/// `disk_tree_hash` equals the `post_run_hash` we just computed. The +/// caller (post-run hook) is responsible for clearing the pending +/// marker and running GC. +fn push_post_run_snapshot( + store: &SnapshotStore, + session_id: &str, + source_root: &std::path::Path, + pending: &PendingSnapshot, + post_run_hash: String, +) -> Result<(), String> { + let mut timeline = store.read_timeline()?; + + // Step 1: if the user is detached (cursor < len-1), any snapshots in + // the future are now orphaned by this new commit — git-style discard. + if timeline.cursor + 1 < timeline.snapshots.len() { + let discarded: Vec = timeline + .snapshots + .drain(timeline.cursor + 1..) + .map(|meta| meta.id) + .collect(); + for id in &discarded { + let _ = store.delete_manifest(id); + } + } + + // Step 2: ensure timeline[cursor] matches the pre-run disk state. If + // the timeline is empty, or the cursor snapshot's hash doesn't match + // pending.pre_run_tree_hash (e.g. timeline was seeded from an older + // run and disk has drifted, or this is the very first commit ever), + // push the pending manifest as a new "pre-run" baseline entry first. + let cursor_matches_pre_run = timeline + .snapshots + .get(timeline.cursor) + .map(|meta| meta.disk_tree_hash == pending.pre_run_tree_hash) + .unwrap_or(false); + + if !cursor_matches_pre_run { + let baseline_meta = + store.push_snapshot(&pending.manifest, pending.pre_run_tree_hash.clone())?; + if timeline.snapshots.is_empty() { + timeline.snapshots.push(baseline_meta); + timeline.cursor = 0; + } else { + // Rebasing: insert the baseline right after the current + // cursor so that subsequent undo still walks older history. + let insert_at = timeline.cursor + 1; + timeline.snapshots.insert(insert_at, baseline_meta); + timeline.cursor = insert_at; + } + } + + // Step 3: snapshot the current (post-run) disk and push as a new + // entry after the baseline. + let post_run_manifest = store.snapshot(source_root)?; + let post_run_meta = store.push_snapshot(&post_run_manifest, post_run_hash)?; + timeline.snapshots.push(post_run_meta); + timeline.cursor = timeline.snapshots.len() - 1; + + // Step 4: enforce retention cap. Drop oldest entries first and + // shift cursor to compensate. + while timeline.snapshots.len() > MAX_TIMELINE_LEN { + let dropped = timeline.snapshots.remove(0); + let _ = store.delete_manifest(&dropped.id); + if timeline.cursor > 0 { + timeline.cursor -= 1; + } + } + + store.write_timeline(&timeline)?; + + let _ = session_id; // reserved for future structured logging + Ok(()) +} + +/// Pull the current timeline from disk and mirror its navigation state +/// onto the session's `undo_state` hint. Best-effort — if the timeline +/// is unreadable, we leave the existing hint alone rather than wiping +/// it to a default (which would hide the button the next render). +fn refresh_undo_state_hint( + store: &SnapshotStore, + session_id: &str, + folder_detail: &mut folder_sessions::CoworkChatSessionDetail, +) { + let timeline = match store.read_timeline() { + Ok(timeline) => timeline, + Err(error) => { + eprintln!( + "[cowork] timeline: read_timeline failed for session {}: {}", + session_id, error + ); + return; + } + }; + let (current, total) = timeline.display_position(); + folder_detail.undo_state = FolderUndoState { + can_undo: timeline.can_undo(), + can_redo: timeline.can_redo(), + current, + total, + }; +} + +pub fn resolve_prompt_context( + session: &LocalCoworkSession, + explicit_prompt_context: Option, +) -> Option { + explicit_prompt_context.or_else(|| match session { + LocalCoworkSession::Homepage(_) => None, + LocalCoworkSession::Folder(detail) => { + Some(folder_chat_prompt::build_folder_prompt_context(detail)) + } + }) +} + +pub fn build_local_message( + role: CoworkChatMessageRole, + content: String, + is_think_message: bool, + created_at: Option, +) -> CoworkChatMessage { + CoworkChatMessage { + id: generate_message_id(), + role, + content, + created_at: created_at.unwrap_or_else(now_iso), + is_think_message: is_think_message.then_some(true), + } +} + +pub fn build_session_created_event(session: &CoworkChatSessionDetail) -> CoworkChatEvent { + CoworkChatEvent::Session(CoworkChatSessionEvent { + event_type: "session.created".to_string(), + session: build_summary(session), + }) +} + +pub fn build_session_updated_event(session: &CoworkChatSessionDetail) -> CoworkChatEvent { + CoworkChatEvent::Session(CoworkChatSessionEvent { + event_type: "session.updated".to_string(), + session: build_summary(session), + }) +} + +pub fn build_status_updated_event( + scope: CoworkChatScope, + session_id: String, + status: CoworkChatRunStatus, +) -> CoworkChatEvent { + CoworkChatEvent::Status(CoworkChatStatusEvent { + event_type: "status.updated".to_string(), + scope, + session_id, + status, + }) +} + +pub fn build_message_created_event( + scope: CoworkChatScope, + session_id: String, + message: CoworkChatMessage, +) -> CoworkChatEvent { + CoworkChatEvent::Message(CoworkChatMessageEvent { + event_type: "message.created".to_string(), + scope, + session_id, + message, + }) +} + +pub fn build_files_updated_event( + scope: CoworkChatScope, + session_id: String, + files: Vec, +) -> CoworkChatEvent { + CoworkChatEvent::Files(CoworkChatFilesEvent { + event_type: "files.updated".to_string(), + scope, + session_id, + files, + }) +} + +pub fn emit_local_session_started( + app: &AppHandle, + session: &LocalCoworkSession, + include_session_created: bool, +) { + let base = session.base(); + + if include_session_created { + let _ = emit_cowork_stream_event(app, &build_session_created_event(base)); + } + + let _ = emit_cowork_stream_event(app, &build_session_updated_event(base)); + + if let Some(message) = base.messages.last().cloned() { + let _ = emit_cowork_stream_event( + app, + &build_message_created_event(base.scope, base.id.clone(), message), + ); + } + + let _ = emit_cowork_stream_event( + app, + &build_status_updated_event(base.scope, base.id.clone(), base.run_status), + ); +} + +pub fn emit_session_updated(app: &AppHandle, session: &LocalCoworkSession) { + let _ = emit_cowork_stream_event(app, &build_session_updated_event(session.base())); +} + +pub fn emit_local_terminal_state( + app: &AppHandle, + session: &LocalCoworkSession, + include_latest_message: bool, +) { + let base = session.base(); + + let _ = emit_cowork_stream_event(app, &build_session_updated_event(base)); + + if include_latest_message { + if let Some(message) = base.messages.last().cloned() { + let _ = emit_cowork_stream_event( + app, + &build_message_created_event(base.scope, base.id.clone(), message), + ); + } + } + + let _ = emit_cowork_stream_event( + app, + &build_files_updated_event(base.scope, base.id.clone(), base.files.clone()), + ); + + let _ = emit_cowork_stream_event( + app, + &build_status_updated_event(base.scope, base.id.clone(), base.run_status), + ); +} + +fn normalize_local_session_runtime(session: &mut LocalCoworkSession) { + session.base_mut().normalize_runtime_binding(); +} + +fn merge_runtime_events( + existing_events: Vec, + incoming_events: Vec, +) -> Vec { + let mut merged = existing_events; + + for event in incoming_events { + if !merged + .iter() + .any(|current| is_same_runtime_event(current, &event)) + { + merged.push(event); + } + } + + sort_runtime_events(&mut merged); + merged +} + +fn sort_runtime_events(events: &mut [CoworkChatRuntimeEvent]) { + events.sort_by(|left, right| { + let left_time = left + .runtime_created_at + .as_deref() + .unwrap_or(left.emitted_at.as_str()); + let right_time = right + .runtime_created_at + .as_deref() + .unwrap_or(right.emitted_at.as_str()); + + left_time + .cmp(right_time) + .then_with(|| left.runtime_event_type.cmp(&right.runtime_event_type)) + .then_with(|| left.runtime_event_id.cmp(&right.runtime_event_id)) + .then_with(|| left.emitted_at.cmp(&right.emitted_at)) + }); +} + +fn is_same_runtime_event(left: &CoworkChatRuntimeEvent, right: &CoworkChatRuntimeEvent) -> bool { + match ( + left.runtime_event_id.as_deref(), + right.runtime_event_id.as_deref(), + ) { + (Some(left_id), Some(right_id)) => { + left.runtime_event_type == right.runtime_event_type && left_id == right_id + } + _ => { + left.runtime_event_type == right.runtime_event_type + && left.runtime_created_at == right.runtime_created_at + && left.emitted_at == right.emitted_at + && left.content == right.content + } + } +} + +fn resolve_store_for_scope(scope: CoworkChatScope) -> &'static dyn FeatureSessionStore { + match scope { + CoworkChatScope::Homepage => &HOMEPAGE_SESSION_STORE, + CoworkChatScope::IntelligentFolder => &FOLDER_SESSION_STORE, + } +} + +fn resolve_store_for_session(session: &LocalCoworkSession) -> &'static dyn FeatureSessionStore { + match session { + LocalCoworkSession::Homepage(_) => &HOMEPAGE_SESSION_STORE, + LocalCoworkSession::Folder(_) => &FOLDER_SESSION_STORE, + } +} + +fn build_summary(session: &CoworkChatSessionDetail) -> CoworkChatSessionSummary { + CoworkChatSessionSummary { + id: session.id.clone(), + scope: session.scope, + title: session.title.clone(), + preview: session.preview.clone(), + updated_at: session.updated_at.clone(), + message_count: session.message_count, + } +} + +fn build_preview_from_messages(messages: &[CoworkChatMessage]) -> String { + messages + .iter() + .rev() + .find(|message| { + message.role == CoworkChatMessageRole::Assistant && !message.content.trim().is_empty() + }) + .or_else(|| { + messages + .iter() + .rev() + .find(|message| !message.content.trim().is_empty()) + }) + .map(|message| truncate_preview(&message.content)) + .unwrap_or_default() +} + +fn build_homepage_session_title(content: &str) -> String { + let normalized = content.split_whitespace().collect::>().join(" "); + let title: String = normalized.chars().take(60).collect(); + + if title.trim().is_empty() { + "New cowork chat".to_string() + } else { + title.trim().to_string() + } +} + +fn truncate_preview(value: &str) -> String { + let normalized = value.split_whitespace().collect::>().join(" "); + if normalized.chars().count() <= 120 { + normalized + } else { + let truncated: String = normalized.chars().take(117).collect(); + format!("{truncated}...") + } +} diff --git a/frontend/src-tauri/src/cowork/string_utils.rs b/frontend/src-tauri/src/cowork/string_utils.rs new file mode 100644 index 000000000..d9f88c3ef --- /dev/null +++ b/frontend/src-tauri/src/cowork/string_utils.rs @@ -0,0 +1,8 @@ +pub fn normalize_optional_string(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} diff --git a/frontend/src-tauri/src/cowork/time_utils.rs b/frontend/src-tauri/src/cowork/time_utils.rs new file mode 100644 index 000000000..e20a06758 --- /dev/null +++ b/frontend/src-tauri/src/cowork/time_utils.rs @@ -0,0 +1,9 @@ +use chrono::{SecondsFormat, Utc}; + +pub fn now_iso() -> String { + Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true) +} + +pub fn generate_message_id() -> String { + format!("cowork-msg-{}", Utc::now().timestamp_millis()) +} diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index d6ce642e6..5de100fa7 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -2,16 +2,46 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // Learn more about Tauri commands at https://tauri.app/v1/guides/features/command +mod cowork; + #[tauri::command] fn greet(name: &str) -> String { format!("Hello, {}! You've been greeted from Rust!", name) } fn main() { + cowork::bootstrap::install_panic_diagnostics(); + + if let Some(exit_code) = cowork::bootstrap::maybe_startup_exit_code_from_env_args() { + std::process::exit(exit_code); + } + tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_process::init()) - .invoke_handler(tauri::generate_handler![greet]) + .manage(cowork::agent_remote::auth::RemoteAuthState::default()) + .invoke_handler(tauri::generate_handler![ + greet, + cowork::agent_remote::auth::sync_cowork_auth_context, + cowork::runtime::send_cowork_chat_message, + cowork::chat_commands::stop_cowork_chat_session, + cowork::homepage::chat_sessions::list_homepage_chat_sessions, + cowork::homepage::chat_sessions::get_homepage_chat_session, + cowork::homepage::chat_sessions::create_homepage_chat_session, + cowork::homepage::chat_sessions::update_homepage_chat_session, + cowork::homepage::chat_sessions::rename_homepage_chat_session, + cowork::homepage::chat_sessions::delete_homepage_chat_session, + cowork::intelligent_folder::file_tree::read_path_tree, + cowork::intelligent_folder::sessions::list_folder_sessions, + cowork::intelligent_folder::sessions::get_folder_session, + cowork::intelligent_folder::sessions::create_folder_session, + cowork::intelligent_folder::sessions::update_folder_session, + cowork::intelligent_folder::sessions::rename_folder_session, + cowork::intelligent_folder::sessions::delete_folder_session, + cowork::intelligent_folder::undo_commands::undo_cowork_folder, + cowork::intelligent_folder::undo_commands::redo_cowork_folder + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index b821db87f..1982f72cd 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -22,6 +22,9 @@ "plugins": { "process": { "active": true + }, + "shell": { + "open": true } }, "app": { @@ -33,8 +36,8 @@ "title": "II Agent", "width": 1000, "height": 600, - "dragDropEnabled": false + "dragDropEnabled": true } ] } -} \ No newline at end of file +} diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 8e9392cc5..2a419d22f 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -306,6 +306,19 @@ const createAppRouter = () => } } }, + { + path: 'cowork', + async lazy() { + const { Component } = await import('@/app/routes/cowork') + return { + Component: () => ( + + + + ) + } + } + }, { path: ':sessionId', async lazy() { diff --git a/frontend/src/app/routes/cowork.tsx b/frontend/src/app/routes/cowork.tsx new file mode 100644 index 000000000..23ea68630 --- /dev/null +++ b/frontend/src/app/routes/cowork.tsx @@ -0,0 +1,7 @@ +import CoworkPage from '@/components/cowork/cowork-page' + +export function CoworkRoute() { + return +} + +export const Component = CoworkRoute diff --git a/frontend/src/app/routes/home.tsx b/frontend/src/app/routes/home.tsx index 9486047da..851b8a12b 100644 --- a/frontend/src/app/routes/home.tsx +++ b/frontend/src/app/routes/home.tsx @@ -37,6 +37,7 @@ import { selectCurrentQuestion, selectQuestionMode, setCurrentQuestion, + setQuestionMode, setSelectedGitHubRepository, useAppDispatch, useAppSelector @@ -61,6 +62,12 @@ function HomePageContent() { const questionMode = useAppSelector(selectQuestionMode) const isChatMode = questionMode === QUESTION_MODE.CHAT + useEffect(() => { + if (questionMode === QUESTION_MODE.COWORK) { + dispatch(setQuestionMode(QUESTION_MODE.AGENT)) + } + }, [dispatch, questionMode]) + const toggleTheme = () => { setTheme(theme === 'dark' ? 'light' : 'dark') } diff --git a/frontend/src/app/routes/login.tsx b/frontend/src/app/routes/login.tsx index 8b278afef..ec0d91f69 100644 --- a/frontend/src/app/routes/login.tsx +++ b/frontend/src/app/routes/login.tsx @@ -1,6 +1,6 @@ import { useGoogleLogin } from '@react-oauth/google' import { useCallback, useEffect, useMemo, useRef } from 'react' -import { Link, useNavigate } from 'react-router' +import { Link, useNavigate, useSearchParams } from 'react-router' import { useForm } from 'react-hook-form' import { z } from 'zod' import { zodResolver } from '@hookform/resolvers/zod' @@ -11,7 +11,6 @@ import { Button } from '@/components/ui/button' import { Icon } from '@/components/ui/icon' import { Form, FormControl, FormField, FormItem } from '@/components/ui/form' import { Input } from '@/components/ui/input' -import { ACCESS_TOKEN } from '@/constants/auth' import { authService } from '@/services/auth.service' import { useAppDispatch } from '@/state/store' import { setUser } from '@/state/slice/user' @@ -19,6 +18,10 @@ import { fetchWishlist } from '@/state/slice/favorites' import { fetchPins } from '@/state/slice/pins' import { toast } from 'sonner' import { useIsSageTheme } from '@/hooks/use-is-sage-theme' +import { storeAccessToken } from '@/utils/auth-token' + +const isTauri = !!(window as unknown as { __TAURI_INTERNALS__: unknown }) + .__TAURI_INTERNALS__ type IiAuthPayload = { access_token: string @@ -30,10 +33,53 @@ type IiAuthPayload = { export function LoginPage() { const { t } = useTranslation() const navigate = useNavigate() + const [searchParams] = useSearchParams() const { loginWithAuthCode } = useAuth() const dispatch = useAppDispatch() const isSage = useIsSageTheme() + const apiBaseUrl = useMemo( + () => import.meta.env.VITE_API_URL || 'http://localhost:8000', + [] + ) + + // System browser opened this page with desktop_state → fetch Google OAuth URL and redirect + const desktopState = searchParams.get('desktop_state') + useEffect(() => { + if (!desktopState || searchParams.get('desktop_auth')) return + authService + .getDesktopGoogleLoginUrl(desktopState) + .then((url) => { + window.location.href = url + }) + .catch((err) => { + console.error('Failed to get Google login URL:', err) + }) + }, [desktopState, searchParams]) + + if (desktopState && !searchParams.get('desktop_auth')) { + return null + } + + // Backend redirected back here after Google login completes + if (searchParams.get('desktop_auth') === 'success') { + return ( +
+

+ {t('auth.loginSuccessful', { + defaultValue: 'Login successful!' + })} +

+

+ {t('auth.closeTabMessage', { + defaultValue: + 'You can close this tab and return to the app.' + })} +

+
+ ) + } + const FormSchema = useMemo( () => z.object({ @@ -85,10 +131,6 @@ export function LoginPage() { } }) - const apiBaseUrl = useMemo( - () => import.meta.env.VITE_API_URL || 'http://localhost:8000', - [] - ) const apiOrigin = useMemo(() => { try { return new URL(apiBaseUrl).origin @@ -113,17 +155,24 @@ export function LoginPage() { authHandledRef.current = true try { - localStorage.setItem(ACCESS_TOKEN, payload.access_token) - window.dispatchEvent(new CustomEvent('auth-token-set')) + storeAccessToken(payload.access_token) const userRes = await authService.getCurrentUser() dispatch(setUser(userRes)) dispatch(fetchWishlist()) dispatch(fetchPins()) + // Focus desktop app window after login + if (isTauri) { + const { getCurrentWindow } = await import( + '@tauri-apps/api/window' + ) + await getCurrentWindow().setFocus() + } + navigate('/') } catch (error) { - console.error('Failed to finalize II login:', error) + console.error('Failed to finalize login:', error) authHandledRef.current = false } }, @@ -141,7 +190,11 @@ export function LoginPage() { payload?: IiAuthPayload } - if (!data || data.type !== 'ii-auth-success') { + if ( + !data || + (data.type !== 'ii-auth-success' && + data.type !== 'google-auth-success') + ) { return } @@ -154,13 +207,19 @@ export function LoginPage() { useEffect(() => { const hash = window.location.hash - if (!hash || !hash.includes('ii-auth=')) { - return - } + if (!hash) return + + // Support both II and Google auth hash-fragment fallbacks + const authKey = hash.includes('ii-auth=') + ? 'ii-auth' + : hash.includes('google-auth=') + ? 'google-auth' + : null + if (!authKey) return const params = new URLSearchParams(hash.slice(1)) - const encoded = params.get('ii-auth') - params.delete('ii-auth') + const encoded = params.get(authKey) + params.delete(authKey) const cleanHash = params.toString() const cleanUrl = `${window.location.pathname}${window.location.search}${cleanHash ? `#${cleanHash}` : ''}` @@ -176,11 +235,36 @@ export function LoginPage() { ) as IiAuthPayload void handleAuthSuccess(payload) } catch (error) { - console.error('Failed to parse II auth payload from hash:', error) + console.error('Failed to parse auth payload from hash:', error) authHandledRef.current = false } }, [handleAuthSuccess]) + const loginWithGoogleDesktop = useCallback(async () => { + authHandledRef.current = false + + const state = crypto.randomUUID() + const frontendOrigin = + import.meta.env.VITE_FRONTEND_URL || 'http://localhost:1420' + const url = `${frontendOrigin}/login?desktop_state=${state}` + + const { open } = await import('@tauri-apps/plugin-shell') + await open(url) + + // Poll for token until backend stores it after Google callback + const poll = setInterval(async () => { + try { + const token = await authService.pollDesktopToken(state) + if (!token) return + clearInterval(poll) + void handleAuthSuccess(token) + } catch { + // keep polling + } + }, 2000) + setTimeout(() => clearInterval(poll), 5 * 60 * 1000) + }, [handleAuthSuccess]) + const loginWithII = useCallback(() => { authHandledRef.current = false @@ -326,7 +410,9 @@ export function LoginPage() {

- {t('auth.privacyNotice')}{' '} -

+ {t('auth.privacyNotice')}

>({ resolver: zodResolver(FormSchema), @@ -50,6 +68,80 @@ export function SignupPage() { } }) + const apiBaseUrl = useMemo( + () => import.meta.env.VITE_API_URL || 'http://localhost:8000', + [] + ) + + const handleAuthSuccess = useCallback( + async (payload: AuthPayload | null | undefined) => { + if (!payload || typeof payload.access_token !== 'string') { + authHandledRef.current = false + return + } + if (authHandledRef.current) return + authHandledRef.current = true + + try { + storeAccessToken(payload.access_token) + const userRes = await authService.getCurrentUser() + dispatch(setUser(userRes)) + dispatch(fetchWishlist()) + navigate('/') + } catch (error) { + console.error('Failed to finalize Google login:', error) + authHandledRef.current = false + } + }, + [dispatch, navigate] + ) + + const apiOrigin = useMemo(() => { + try { + return new URL(apiBaseUrl).origin + } catch { + return apiBaseUrl + } + }, [apiBaseUrl]) + + useEffect(() => { + const handler = (event: MessageEvent) => { + if (event.origin !== apiOrigin) return + const data = event.data as { + type?: string + payload?: AuthPayload + } + if (!data || data.type !== 'google-auth-success') return + void handleAuthSuccess(data.payload) + } + window.addEventListener('message', handler) + return () => window.removeEventListener('message', handler) + }, [apiOrigin, handleAuthSuccess]) + + const loginWithGoogleDesktop = useCallback(async () => { + authHandledRef.current = false + + const state = crypto.randomUUID() + const frontendOrigin = + import.meta.env.VITE_FRONTEND_URL || 'http://localhost:1420' + const url = `${frontendOrigin}/login?desktop_state=${state}` + + const { open } = await import('@tauri-apps/plugin-shell') + await open(url) + + const poll = setInterval(async () => { + try { + const token = await authService.pollDesktopToken(state) + if (!token) return + clearInterval(poll) + void handleAuthSuccess(token) + } catch { + // keep polling + } + }, 2000) + setTimeout(() => clearInterval(poll), 5 * 60 * 1000) + }, [handleAuthSuccess]) + const onSubmit = async (data: z.infer) => { console.log(data) } @@ -179,7 +271,9 @@ export function SignupPage() {

- {questionMode === QUESTION_MODE.AGENT + {isAgenticQuestionMode(questionMode) ? t('agent.settings') : t('agent.chatSettings')} diff --git a/frontend/src/components/cowork/chat/cowork-chat-box.tsx b/frontend/src/components/cowork/chat/cowork-chat-box.tsx new file mode 100644 index 000000000..4baaf3d8b --- /dev/null +++ b/frontend/src/components/cowork/chat/cowork-chat-box.tsx @@ -0,0 +1,289 @@ +import { AnimatePresence, motion } from 'framer-motion' +import { useMemo, useRef, useState } from 'react' +import clsx from 'clsx' +import ChatMessage from '@/components/agent/chat-message' +import { Button } from '@/components/ui/button' +import { useAppDispatch } from '@/state' +import { setCurrentQuestion } from '@/state' +import type { ActionStep } from '@/typings/agent' +import type { + CoworkChatScope, + CoworkChatFile, + CoworkChatSessionDetail, + CoworkLiveSessionState +} from '@/typings/cowork' +import { useCoworkChatMessageAdapter } from './use-cowork-chatmessage-adapter' +import CoworkModelSelector from './cowork-model-selector' + +interface CoworkChatBoxProps { + className?: string + isVisible?: boolean + scope: CoworkChatScope + activeSession: CoworkChatSessionDetail | null + liveSession?: CoworkLiveSessionState | null + isLoading?: boolean + isSending?: boolean + isInputLocked?: boolean + onSendMessage: (content: string) => Promise | void + onStopSession?: () => Promise | void + onSelectAction?: (action: ActionStep) => void +} + +type CoworkChatTab = 'chat' | 'files' + +const formatFileSize = (size: number) => { + if (size >= 1024 * 1024) { + return `${(size / (1024 * 1024)).toFixed(1)} MB` + } + if (size >= 1024) { + return `${Math.round(size / 1024)} KB` + } + return `${size} B` +} + +const COWORK_HIDE_UPLOAD_SCOPE_CLASS = 'cowork-chat-box--hide-upload' + +const CoworkChatBox = ({ + className = '', + isVisible = true, + scope, + activeSession, + liveSession = null, + isLoading = false, + isSending = false, + isInputLocked = false, + onSendMessage, + onStopSession, + onSelectAction +}: CoworkChatBoxProps) => { + const dispatch = useAppDispatch() + const [activeTab, setActiveTab] = useState('chat') + const messagesEndRef = useRef(null) + + useCoworkChatMessageAdapter({ + activeSession, + liveSession, + isLoading, + isSending + }) + + const sessionFiles = activeSession?.files ?? [] + const hasUserStartedChat = Boolean( + activeSession?.messages.some((message) => message.role === 'user') + ) + const sessionContentKey = useMemo( + () => activeSession?.id ?? `empty-${scope}`, + [activeSession?.id, scope] + ) + const emptyStateDescription = + scope === 'intelligent-folder' + ? 'Start a Cowork chat session to understand, discuss, and folder your folder.' + : 'Start a new Cowork chat to discuss your task.' + const responsiveChatBoxWidthClass = 'md:w-[clamp(320px,38vw,600px)]' + + if (!isVisible) return null + + return ( +
+ +
+ + + +
+ +
+
+
+ { + if (action) { + onSelectAction?.(action) + } + }} + setCurrentQuestion={(value) => + dispatch(setCurrentQuestion(value)) + } + handleKeyDown={(event) => { + dispatch( + setCurrentQuestion( + event.currentTarget.value + ) + ) + }} + handleQuestionSubmit={(question) => { + if (isInputLocked || isSending) { + return + } + dispatch(setCurrentQuestion('')) + void onSendMessage(question) + }} + handleEnhancePrompt={() => {}} + handleCancel={() => { + void onStopSession?.() + }} + handleEditMessage={() => {}} + connectWebSocket={() => {}} + handleReviewSession={() => {}} + submitDisabled={isInputLocked} + /> +
+ + {!hasUserStartedChat && !isSending && !isLoading && ( +
+
+

+ New chat +

+

+ {emptyStateDescription} +

+
+
+ )} + +
+
+
+ Switching session... +
+
+
+
+
+ +
+
+
+ + +
+

+ All files +

+

+ Files associated with the active Cowork + session. +

+
+
+ {sessionFiles.length > 0 ? ( + sessionFiles.map( + (file: CoworkChatFile) => ( +
+
+
+

+ {file.file_name} +

+

+ { + file.content_type + } +

+
+
+ {formatFileSize( + file.file_size + )} +
+
+
+ ) + ) + ) : ( +
+ No files in this Cowork session yet. +
+ )} +
+
+
+
+
+
+ Switching session... +
+
+
+
+
+
+
+ ) +} + +export default CoworkChatBox diff --git a/frontend/src/components/cowork/chat/cowork-chatmessage-contract.ts b/frontend/src/components/cowork/chat/cowork-chatmessage-contract.ts new file mode 100644 index 000000000..be940f65b --- /dev/null +++ b/frontend/src/components/cowork/chat/cowork-chatmessage-contract.ts @@ -0,0 +1,13 @@ +export type CoworkTranscriptMessageKind = 'thinking' | 'response' + +const normalizeCoworkTranscriptAnchor = (anchor: string) => + anchor.replace(/\s+/g, '-').trim() + +export const buildCoworkTranscriptMessageId = ( + kind: CoworkTranscriptMessageKind, + anchor: string +) => `cowork-transcript:${kind}:${normalizeCoworkTranscriptAnchor(anchor)}` + +export const resolveCoworkTranscriptAnchor = ( + ...candidates: Array +) => candidates.find((candidate) => typeof candidate === 'string' && candidate.trim()) diff --git a/frontend/src/components/cowork/chat/cowork-model-selector.tsx b/frontend/src/components/cowork/chat/cowork-model-selector.tsx new file mode 100644 index 000000000..8bd84619e --- /dev/null +++ b/frontend/src/components/cowork/chat/cowork-model-selector.tsx @@ -0,0 +1,178 @@ +import * as SelectPrimitive from '@radix-ui/react-select' +import clsx from 'clsx' +import { ChevronDownIcon } from 'lucide-react' + +import { PROVIDERS_NAME, getProviderKey } from '@/constants/models' +import { + selectAvailableModels, + selectSelectedModel, + setSelectedModel, + useAppDispatch, + useAppSelector +} from '@/state' +import { + Select, + SelectContent, + SelectItem +} from '@/components/ui/select' +import type { IModel } from '@/typings/settings' + +interface CoworkModelSelectorProps { + className?: string +} + +// Providers whose default svg is painted white (for dark bgs). On light mode +// we either swap to a *-dark.svg sibling (openai, anthropic) or use a CSS +// brightness-0 filter to darken the white paths in place (custom). +const LIGHT_SRC_OVERRIDE: Record = { + openai: '/images/openai-dark.svg', + anthropic: '/images/anthropic-dark.svg' +} +const INVERTS_IN_LIGHT_MODE = new Set(['custom']) + +// Mirror the active-state styling of the Chat / All files tab buttons in +// cowork-chat-box.tsx — but apply it as a :hover (and :data-[state=open]) +// effect so the selector visually "presses in" when the user rolls over or +// opens it, identical to how a tab becomes active. +const TRIGGER_CLASS = clsx( + 'group relative flex h-7 cursor-pointer items-center gap-2 rounded-full', + 'border border-sky-blue px-3 text-xs font-semibold outline-none', + 'transition-colors', + // Idle — same as an inactive tab button + 'border-firefly text-firefly dark:border-sky-blue dark:text-sky-blue', + // Hover / open — same as the active tab button + 'hover:bg-firefly hover:border-firefly hover:text-sky-blue-2', + 'dark:hover:bg-sky-blue dark:hover:border-sky-blue-2 dark:hover:text-black', + 'data-[state=open]:bg-firefly data-[state=open]:border-firefly data-[state=open]:text-sky-blue-2', + 'dark:data-[state=open]:bg-sky-blue dark:data-[state=open]:border-sky-blue-2 dark:data-[state=open]:text-black', + 'disabled:cursor-not-allowed disabled:opacity-50' +) + +interface ProviderIconProps { + providerKey: string + className?: string +} + +const ProviderIcon = ({ providerKey, className }: ProviderIconProps) => { + if (!PROVIDERS_NAME[providerKey]) return null + const defaultSrc = `/images/${providerKey}.svg` + const lightSrc = LIGHT_SRC_OVERRIDE[providerKey] + const invertInLight = INVERTS_IN_LIGHT_MODE.has(providerKey) + + // Case 1: separate light-mode asset exists — render two s toggled + // by tailwind's dark: variant. Keeps each asset pristine. + if (lightSrc) { + return ( + <> + {providerKey} + {providerKey} + + ) + } + + // Case 2: no separate asset and the svg is white-filled — darken it on + // light mode via brightness-0 (white → black); leave it untouched on dark. + if (invertInLight) { + return ( + {providerKey} + ) + } + + // Case 3: asset is a rasterised/pattern image that renders fine on both + // backgrounds (gemini, google, …). Use as-is. + return ( + {providerKey} + ) +} + +const renderTriggerLabel = (model: IModel | null | undefined) => { + if (!model) { + return No model + } + const providerKey = getProviderKey(model) + return ( + <> + + {model.model} + + ) +} + +const CoworkModelSelector = ({ className }: CoworkModelSelectorProps) => { + const dispatch = useAppDispatch() + const availableModels = useAppSelector(selectAvailableModels) + const selectedModelId = useAppSelector(selectSelectedModel) + + const hasModels = availableModels.length > 0 + const effectiveModel = + availableModels.find((model) => model.id === selectedModelId) ?? + availableModels[0] ?? + null + // Pass undefined (not '') to Radix when nothing is selected so the + // trigger remains in uncontrolled-empty state rather than flashing a + // placeholder. Radix Select treats empty string as a real value. + const triggerValue = effectiveModel?.id + + return ( + + ) +} + +export default CoworkModelSelector diff --git a/frontend/src/components/cowork/chat/use-cowork-chatmessage-adapter.ts b/frontend/src/components/cowork/chat/use-cowork-chatmessage-adapter.ts new file mode 100644 index 000000000..2a08c1b7f --- /dev/null +++ b/frontend/src/components/cowork/chat/use-cowork-chatmessage-adapter.ts @@ -0,0 +1,325 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + setQuestionMode, + useAppDispatch +} from '@/state' +import { QUESTION_MODE, type Message } from '@/typings/agent' +import { useChatMessageAdapterState } from '@/components/agent/use-chat-message-adapter-state' +import type { + CoworkChatSessionDetail, + CoworkLiveSessionState +} from '@/typings/cowork' +import { + buildCoworkTranscriptMessageId, + resolveCoworkTranscriptAnchor +} from './cowork-chatmessage-contract' + +const mapPersistedMessages = ( + activeSession: CoworkChatSessionDetail | null +): Message[] => + (activeSession?.messages ?? []).map((message) => ({ + id: message.id, + role: message.role, + content: message.content, + timestamp: new Date(message.created_at).getTime(), + isThinkMessage: message.is_think_message + })) + +const mapLiveMessages = ( + liveSession: CoworkLiveSessionState | null +): Message[] => { + if (!liveSession) { + return [] + } + + const nextMessages: Message[] = [] + + if (liveSession.thinking.trim()) { + nextMessages.push({ + id: + liveSession.thinking_message_id ?? + buildCoworkTranscriptMessageId( + 'thinking', + resolveCoworkTranscriptAnchor( + liveSession.thinking_started_at, + liveSession.session_id + ) ?? liveSession.session_id + ), + role: 'assistant', + content: liveSession.thinking, + timestamp: new Date( + liveSession.thinking_started_at ?? Date.now() + ).getTime(), + isThinkMessage: true + }) + } + + if (liveSession.response.trim()) { + nextMessages.push({ + id: + liveSession.response_message_id ?? + buildCoworkTranscriptMessageId( + 'response', + resolveCoworkTranscriptAnchor( + liveSession.response_started_at, + liveSession.session_id + ) ?? liveSession.session_id + ), + role: 'assistant', + content: liveSession.response, + timestamp: new Date( + liveSession.response_started_at ?? Date.now() + ).getTime() + }) + } + + return nextMessages +} + +const STREAM_REVEAL_TICK_MS = 24 +const STREAM_REVEAL_MAX_STEP = 12 + +const useAnimatedStreamText = ( + targetText: string, + streamKey: string | null +) => { + const [renderedText, setRenderedText] = useState(targetText) + const previousStreamKeyRef = useRef(streamKey) + + useEffect(() => { + if (!targetText) { + setRenderedText('') + previousStreamKeyRef.current = streamKey + return + } + + if (previousStreamKeyRef.current !== streamKey) { + previousStreamKeyRef.current = streamKey + setRenderedText('') + return + } + + setRenderedText((currentText) => { + if (!currentText) { + return currentText + } + + if ( + currentText.length > targetText.length || + !targetText.startsWith(currentText) + ) { + return targetText + } + + return currentText + }) + }, [streamKey, targetText]) + + useEffect(() => { + if (!targetText || renderedText === targetText) { + return + } + + if ( + renderedText.length > targetText.length || + !targetText.startsWith(renderedText) + ) { + setRenderedText(targetText) + return + } + + const timeoutId = window.setTimeout(() => { + setRenderedText((currentText) => { + if ( + currentText.length >= targetText.length || + !targetText.startsWith(currentText) + ) { + return targetText + } + + const remainingLength = targetText.length - currentText.length + const nextStep = Math.max( + 1, + Math.min( + STREAM_REVEAL_MAX_STEP, + Math.ceil(remainingLength / 6) + ) + ) + + return targetText.slice(0, currentText.length + nextStep) + }) + }, STREAM_REVEAL_TICK_MS) + + return () => { + window.clearTimeout(timeoutId) + } + }, [renderedText, targetText]) + + return renderedText +} + +const sortTimelineMessages = (messages: Message[]) => + messages + .map((message, index) => ({ message, index })) + .sort((left, right) => { + if (left.message.timestamp === right.message.timestamp) { + return left.index - right.index + } + + return left.message.timestamp - right.message.timestamp + }) + .map(({ message }) => message) + +const buildTranscriptSignature = (message: Message) => + message.action + ? null + : `${message.role}:${message.isThinkMessage ? 'think' : 'text'}:${ + message.content?.trim() ?? '' + }` + +const buildCoworkMessages = ({ + activeSession, + liveSession +}: { + activeSession: CoworkChatSessionDetail | null + liveSession: CoworkLiveSessionState | null +}): Message[] => { + const persistedMessages = mapPersistedMessages(activeSession) + const eventMessages = liveSession?.event_messages ?? [] + const persistedMessageIds = new Set( + persistedMessages.map((message) => message.id) + ) + const persistedTranscriptSignatures = new Set( + persistedMessages + .map(buildTranscriptSignature) + .filter((signature): signature is string => Boolean(signature)) + ) + const liveEventMessages = eventMessages.filter( + (message) => + !persistedMessageIds.has(message.id) && + (message.action || + !persistedTranscriptSignatures.has( + buildTranscriptSignature(message) ?? '' + )) + ) + const liveEventMessageIds = new Set( + liveEventMessages.map((message) => message.id) + ) + const liveMessages = mapLiveMessages(liveSession).filter( + (message) => + !persistedMessageIds.has(message.id) && + !liveEventMessageIds.has(message.id) + ) + + if (liveEventMessages.length === 0 && liveMessages.length === 0) { + return persistedMessages + } + + return sortTimelineMessages([ + ...persistedMessages, + ...liveEventMessages, + ...liveMessages + ]) +} + +const mapCoworkRunStatus = ({ + activeSession, + liveSession, + isSending +}: { + activeSession: CoworkChatSessionDetail | null + liveSession: CoworkLiveSessionState | null + isSending: boolean +}) => { + if (activeSession?.run_status === 'completed') { + return 'completed' + } + + if (activeSession?.run_status === 'waiting_for_input') { + return 'paused' + } + + if (activeSession?.run_status === 'stopped') { + return 'aborted' + } + + if ( + isSending || + activeSession?.run_status === 'thinking' || + Boolean( + liveSession?.thinking.trim() || + liveSession?.response.trim() || + liveSession?.event_messages.length || + liveSession?.tool_calls.length + ) + ) { + return 'running' + } + + return null +} + +interface UseCoworkChatMessageAdapterOptions { + activeSession: CoworkChatSessionDetail | null + liveSession?: CoworkLiveSessionState | null + isLoading?: boolean + isSending?: boolean +} + +export const useCoworkChatMessageAdapter = ({ + activeSession, + liveSession = null, + isLoading = false, + isSending = false +}: UseCoworkChatMessageAdapterOptions) => { + const dispatch = useAppDispatch() + const animatedThinking = useAnimatedStreamText( + liveSession?.thinking ?? '', + liveSession?.thinking_message_id ?? null + ) + const animatedResponse = useAnimatedStreamText( + liveSession?.response ?? '', + liveSession?.response_message_id ?? null + ) + const animatedLiveSession = useMemo(() => { + if (!liveSession) { + return null + } + + return { + ...liveSession, + thinking: animatedThinking, + response: animatedResponse + } + }, [animatedResponse, animatedThinking, liveSession]) + + const messages = useMemo( + () => + buildCoworkMessages({ + activeSession, + liveSession: animatedLiveSession + }), + [activeSession, animatedLiveSession] + ) + const runStatus = useMemo( + () => mapCoworkRunStatus({ activeSession, liveSession, isSending }), + [activeSession, liveSession, isSending] + ) + const isChatLoading = Boolean( + isLoading || + runStatus === 'running' || + activeSession?.run_status === 'thinking' + ) + + useChatMessageAdapterState({ + messages, + runStatus, + isLoading: isChatLoading, + workspaceInfo: '', + resetEditingOnKey: activeSession?.id ?? 'cowork-empty' + }) + + useEffect(() => { + dispatch(setQuestionMode(QUESTION_MODE.COWORK)) + }, [dispatch]) +} diff --git a/frontend/src/components/cowork/cowork-action-utils.ts b/frontend/src/components/cowork/cowork-action-utils.ts new file mode 100644 index 000000000..03123bc85 --- /dev/null +++ b/frontend/src/components/cowork/cowork-action-utils.ts @@ -0,0 +1,216 @@ +import { TOOL, type ActionStep } from '@/typings/agent' + +export const readCoworkRecord = ( + value: unknown +): Record | undefined => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined + } + + return value as Record +} + +export const readCoworkString = ( + record: Record, + key: string +) => { + const value = record[key] + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +const lastPathSegment = (value?: string) => { + if (!value) { + return undefined + } + + const parts = value.split(/[\\/]/).filter(Boolean) + return parts.at(-1) +} + +export const getCoworkSkillName = ( + toolInput: unknown +) => { + const input = readCoworkRecord(toolInput) + + if (!input) { + return undefined + } + + return ( + readCoworkString(input, 'skill_name') ?? + readCoworkString(input, 'skill') + ) +} + +export const getCoworkWasmPrimaryInputFileName = ( + toolInput: unknown +) => { + const input = readCoworkRecord(toolInput) + + if (!input) { + return undefined + } + + if (Array.isArray(input.input_files)) { + for (const entry of input.input_files) { + const file = readCoworkRecord(entry) + if (!file) { + continue + } + + const fileName = + lastPathSegment(readCoworkString(file, 'path')) ?? + readCoworkString(file, 'name') + + if (fileName) { + return fileName + } + } + } + + return ( + lastPathSegment(readCoworkString(input, 'file_path')) ?? + lastPathSegment(readCoworkString(input, 'path')) ?? + lastPathSegment(readCoworkString(input, 'file')) ?? + lastPathSegment(readCoworkString(input, 'filename')) + ) +} + +export const getCoworkWasmModuleName = ( + toolInput: unknown +) => { + const input = readCoworkRecord(toolInput) + return input ? readCoworkString(input, 'module') : undefined +} + +export const getCoworkActionValue = (action?: ActionStep) => { + if (!action) { + return undefined + } + + switch (action.type) { + case TOOL.DESKTOP_SKILL_RUN: + return getCoworkSkillName(action.data.tool_input) + case TOOL.WASM_RUN: + return ( + getCoworkWasmPrimaryInputFileName(action.data.tool_input) ?? + getCoworkWasmModuleName(action.data.tool_input) + ) + default: + return undefined + } +} + +export const formatCoworkBuildHeaderLabel = (action?: ActionStep) => { + if (!action) { + return undefined + } + + if (action.type === TOOL.DESKTOP_SKILL_RUN) { + const skillName = getCoworkSkillName(action.data.tool_input) + return skillName ? `Desktop Skill: ${skillName}` : 'Desktop Skill' + } + + if (action.type === TOOL.WASM_RUN) { + const fileName = getCoworkWasmPrimaryInputFileName( + action.data.tool_input + ) + if (fileName) { + return `Process ${fileName}` + } + + const moduleName = getCoworkWasmModuleName(action.data.tool_input) + return moduleName ? `Process ${moduleName}` : 'Process' + } + + return action.data.tool_display_name || action.data.tool_name +} + +export const inferCoworkToolDisplayName = ({ + toolName, + displayName, + toolInput +}: { + toolName?: string + displayName?: string + toolInput?: unknown +}) => { + const normalizedToolName = toolName?.trim().toLowerCase() + const skillName = getCoworkSkillName(toolInput) + + switch (normalizedToolName) { + case TOOL.DESKTOP_SKILL_RUN: + return 'Desktop Skill' + case TOOL.WASM_RUN: + return 'Process' + case TOOL.SKILL.toLowerCase(): + return skillName ? `Skill: ${skillName}` : 'Skill' + default: + return displayName?.trim() || toolName?.trim() || 'Tool' + } +} + +export const normalizeCoworkToolNameForUi = (toolName?: string) => { + const normalized = toolName?.trim() + if (!normalized) { + return undefined + } + + switch (normalized.toLowerCase()) { + case 'ls': + return TOOL.LS + case 'bash': + return TOOL.BASH + case 'bashinit': + case 'bash_init': + return TOOL.BASH_INIT + case 'bashview': + case 'bash_view': + return TOOL.BASH_VIEW + case 'bashstop': + case 'bash_stop': + return TOOL.BASH_STOP + case 'bashkill': + case 'bash_kill': + return TOOL.BASH_KILL + case 'bashlist': + case 'bash_list': + return TOOL.BASH_LIST + case 'bashwritetoprocess': + case 'bash_write_to_process': + return TOOL.BASH_WRITE_TO_PROCESS + case 'read': + case 'read_file': + return TOOL.READ + case 'write': + case 'write_file': + return TOOL.WRITE + case 'edit': + case 'edit_file': + return TOOL.EDIT + case 'apply_patch': + return TOOL.APPLY_PATCH + case 'todowrite': + case 'todo_write': + return TOOL.TODO_WRITE + case 'glob': + return TOOL.GLOB + case 'grep': + case 'astgrep': + return TOOL.GREP + case 'desktop_skill_run': + return TOOL.DESKTOP_SKILL_RUN + case 'wasm_run': + return TOOL.WASM_RUN + case 'multiedit': + case 'multi_edit': + return TOOL.MULTI_EDIT + case 'list_dir': + return TOOL.LS + default: + return normalized + } +} + +export const isCoworkBuildPanelActionVisible = (action?: ActionStep | null) => + Boolean(action && action.type !== TOOL.DESKTOP_SKILL_RUN) diff --git a/frontend/src/components/cowork/cowork-build-panel.tsx b/frontend/src/components/cowork/cowork-build-panel.tsx new file mode 100644 index 000000000..59c8172d7 --- /dev/null +++ b/frontend/src/components/cowork/cowork-build-panel.tsx @@ -0,0 +1 @@ +export { default } from './cowork-build/cowork-build-panel' diff --git a/frontend/src/components/cowork/cowork-build/cowork-build-panel.tsx b/frontend/src/components/cowork/cowork-build/cowork-build-panel.tsx new file mode 100644 index 000000000..295229523 --- /dev/null +++ b/frontend/src/components/cowork/cowork-build/cowork-build-panel.tsx @@ -0,0 +1,57 @@ +import type { ReactNode } from 'react' +import { Icon } from '@/components/ui/icon' + +interface CoworkBuildPanelProps { + headerLabel: string + viewport: ReactNode + controller?: ReactNode + footerText?: string +} + +const CoworkBuildPanel = ({ + headerLabel, + viewport, + controller, + footerText +}: CoworkBuildPanelProps) => { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ + + {headerLabel} + +
+
+
+
+ {viewport} +
+
+ {controller} +
+ {footerText && ( +

+ {footerText} +

+ )} +
+
+ ) +} + +export default CoworkBuildPanel diff --git a/frontend/src/components/cowork/cowork-build/cowork-build.renderers.tsx b/frontend/src/components/cowork/cowork-build/cowork-build.renderers.tsx new file mode 100644 index 000000000..cfa151a34 --- /dev/null +++ b/frontend/src/components/cowork/cowork-build/cowork-build.renderers.tsx @@ -0,0 +1,209 @@ +import Browser from '@/components/agent/browser' +import SearchBrowser from '@/components/agent/search-browser' +import CodeEditor from '@/components/code-editor' +import Terminal from '@/components/terminal' +import { TOOL, type ActionStep } from '@/typings/agent' +import { formatCoworkBuildHeaderLabel } from '../cowork-action-utils' +import type { + CoworkBuildRendererDefinition, + CoworkBuildRendererKey, + CoworkBuildRendererContext +} from './cowork-build.types' + +const escapeRegExp = (value: string) => + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +const extractOutputJsonString = (raw?: string) => { + if (!raw) { + return undefined + } + + const sectionKeys = ['output.json (merged)', 'output.json'] + + for (const key of sectionKeys) { + const matcher = new RegExp( + `(?:^|\\n\\n)${escapeRegExp(key)}:\\n([\\s\\S]*?)(?=\\n\\n(?:output\\.json(?: \\(merged\\))?|stdout(?: \\(merged\\))?|stderr(?: \\(merged\\))?|output_files|error|chunk_summary):|$)` + ) + const match = raw.match(matcher) + const sectionBody = match?.[1]?.trim() + + if (!sectionBody) { + continue + } + + try { + return JSON.stringify(JSON.parse(sectionBody), null, 2) + } catch { + return sectionBody + } + } + + return undefined +} + +export const coworkBuildEventToolGroups = { + terminal: new Set([ + TOOL.BASH, + TOOL.BASH_INIT, + TOOL.BASH_VIEW, + TOOL.BASH_STOP, + TOOL.BASH_KILL, + TOOL.BASH_WRITE_TO_PROCESS, + TOOL.LS, + TOOL.GLOB, + TOOL.GREP + ]), + code: new Set([ + TOOL.READ, + TOOL.WRITE, + TOOL.EDIT, + TOOL.MULTI_EDIT, + TOOL.APPLY_PATCH, + TOOL.STR_REPLACE_BASED_EDIT + ]), + search: new Set([TOOL.WEB_SEARCH, TOOL.WEB_BATCH_SEARCH]), + browser: new Set([ + TOOL.VISIT, + TOOL.VISIT_COMPRESS, + TOOL.BROWSER_USE, + TOOL.BROWSER_CLICK, + TOOL.BROWSER_CLOSE, + TOOL.BROWSER_CONSOLE_MESSAGES, + TOOL.BROWSER_DRAG, + TOOL.BROWSER_EVALUATE, + TOOL.BROWSER_HANDLE_DIALOG, + TOOL.BROWSER_HOVER, + TOOL.BROWSER_NAVIGATE, + TOOL.BROWSER_NETWORK_REQUESTS, + TOOL.BROWSER_PRESS_KEY, + TOOL.BROWSER_SELECT_OPTION, + TOOL.BROWSER_SNAPSHOT, + TOOL.BROWSER_TAKE_SCREENSHOT, + TOOL.BROWSER_TYPE, + TOOL.BROWSER_WAIT_FOR, + TOOL.BROWSER_TAB_CLOSE, + TOOL.BROWSER_TAB_LIST, + TOOL.BROWSER_TAB_NEW, + TOOL.BROWSER_TAB_SELECT, + TOOL.BROWSER_MOUSE_CLICK_XY, + TOOL.BROWSER_MOUSE_DRAG_XY, + TOOL.BROWSER_MOUSE_MOVE_XY, + TOOL.BROWSER_NAVIGATION, + TOOL.BROWSER_WAIT, + TOOL.BROWSER_VIEW_INTERACTIVE_ELEMENTS, + TOOL.BROWSER_SCROLL_DOWN, + TOOL.BROWSER_SCROLL_UP, + TOOL.BROWSER_SWITCH_TAB, + TOOL.BROWSER_OPEN_NEW_TAB, + TOOL.BROWSER_GET_SELECT_OPTIONS, + TOOL.BROWSER_SELECT_DROPDOWN_OPTION, + TOOL.BROWSER_RESTART, + TOOL.BROWSER_ENTER_TEXT, + TOOL.BROWSER_ENTER_MULTI_TEXTS + ]), + desktopTool: new Set([TOOL.WASM_RUN]) +} satisfies Record> + +const DesktopToolBuildCard = ({ + currentAction, + currentToolCall +}: CoworkBuildRendererContext) => { + const backendResult = + currentToolCall?.result ?? + (typeof currentAction.data.result === 'string' + ? currentAction.data.result + : undefined) + const outputJson = extractOutputJsonString(backendResult) + + return ( +
+
+
+                    {outputJson ??
+                        backendResult ??
+                        `${formatCoworkBuildHeaderLabel(currentAction) || 'Process'} is still running...`}
+                
+
+
+ ) +} + +export const coworkBuildRendererCatalog: Record< + CoworkBuildRendererKey, + CoworkBuildRendererDefinition +> = { + terminal: { + key: 'terminal', + matches: (action: ActionStep) => + coworkBuildEventToolGroups.terminal.has(action.type), + render: ({ currentAction }: CoworkBuildRendererContext) => ( + + ) + }, + code: { + key: 'code', + matches: (action: ActionStep) => + coworkBuildEventToolGroups.code.has(action.type), + render: ({ + currentAction, + previewContent, + previewPath + }: CoworkBuildRendererContext) => ( + + ) + }, + search: { + key: 'search', + matches: (action: ActionStep) => + coworkBuildEventToolGroups.search.has(action.type), + render: ({ + searchKeyword, + searchResults + }: CoworkBuildRendererContext) => ( + + ) + }, + browser: { + key: 'browser', + matches: (action: ActionStep) => + coworkBuildEventToolGroups.browser.has(action.type), + render: ({ browserUrl, browserRaw }: CoworkBuildRendererContext) => ( + + ) + }, + desktopTool: { + key: 'desktopTool', + matches: (action: ActionStep) => + coworkBuildEventToolGroups.desktopTool.has(action.type), + render: (context: CoworkBuildRendererContext) => ( + + ) + } +} + +export const pickCoworkBuildRenderers = ( + ...keys: CoworkBuildRendererKey[] +): CoworkBuildRendererDefinition[] => + keys.map((key) => coworkBuildRendererCatalog[key]) diff --git a/frontend/src/components/cowork/cowork-build/cowork-build.shared.tsx b/frontend/src/components/cowork/cowork-build/cowork-build.shared.tsx new file mode 100644 index 000000000..c879f5fb6 --- /dev/null +++ b/frontend/src/components/cowork/cowork-build/cowork-build.shared.tsx @@ -0,0 +1,472 @@ +import { useEffect, useMemo, useState } from 'react' +import Action from '@/components/agent/action' +import { Button } from '@/components/ui/button' +import { Icon } from '@/components/ui/icon' +import { Slider } from '@/components/ui/slider' +import { parseJson } from '@/lib/utils' +import { TOOL, type ActionStep } from '@/typings/agent' +import { isCoworkBuildPanelActionVisible } from '../cowork-action-utils' +import type { + CoworkBuildActionMessage, + CoworkBuildRendererContext, + CoworkBuildRendererDefinition, + CoworkBuildState, + UseCoworkBuildStateOptions +} from './cowork-build.types' +import { coworkBuildEventToolGroups } from './cowork-build.renderers' + +const stringifyValue = (value: unknown) => { + if (typeof value === 'string') return value + if (value === null || value === undefined) return '' + + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } +} + +const getPreviewPath = (action?: ActionStep) => { + if (action?.type === TOOL.APPLY_PATCH && action.data.tool_input?.changes) { + const firstPath = Object.keys(action.data.tool_input.changes)[0] + if (firstPath) { + return firstPath + } + } + + return ( + action?.data.tool_input?.path || + action?.data.tool_input?.file_path || + action?.data.tool_input?.file || + action?.data.tool_input?.filename + ) +} + +const getPreviewContent = (action?: ActionStep) => { + if (!action) return '' + + if (action.type === TOOL.WRITE) { + return stringifyValue(action.data.tool_input?.content) + } + + if (action.type === TOOL.EDIT || action.type === TOOL.MULTI_EDIT) { + return stringifyValue( + action.data.tool_input?.new_string ?? action.data.result + ) + } + + if (action.type === TOOL.STR_REPLACE_BASED_EDIT) { + return stringifyValue( + action.data.tool_input?.file_text ?? action.data.result + ) + } + + if (action.type === TOOL.APPLY_PATCH && action.data.tool_input?.changes) { + const firstPath = Object.keys(action.data.tool_input.changes)[0] + const firstChange = firstPath + ? action.data.tool_input.changes[firstPath] + : undefined + + return stringifyValue( + firstChange?.update?.unified_diff || + firstChange?.add?.content || + firstChange?.delete?.content || + action.data.result + ) + } + + return stringifyValue(action.data.result) +} + +const getRequestedActionIndex = ( + actionMessages: CoworkBuildActionMessage[], + requestedAction?: ActionStep | null +) => { + if (!requestedAction) { + return -1 + } + + const requestedToolCallId = requestedAction.data.tool_call_id + if (requestedToolCallId) { + const toolCallIndex = actionMessages.findIndex( + (message) => + message.action.data.tool_call_id === requestedToolCallId + ) + if (toolCallIndex >= 0) { + return toolCallIndex + } + } + + const typeAndInputIndex = actionMessages.findIndex( + (message) => + message.action.type === requestedAction.type && + message.action.data.tool_input === requestedAction.data.tool_input + ) + + if (typeAndInputIndex >= 0) { + return typeAndInputIndex + } + + return actionMessages.findIndex( + (message) => + message.action.type === requestedAction.type && + message.action.data.tool_name === requestedAction.data.tool_name + ) +} + +export const useCoworkBuildState = ({ + liveSession = null, + requestedAction = null, + requestedActionToken = 0 +}: UseCoworkBuildStateOptions): CoworkBuildState => { + const actionMessages = useMemo( + () => + (liveSession?.event_messages ?? []).filter( + (message) => + message.action && + isCoworkBuildPanelActionVisible(message.action) + ) as CoworkBuildActionMessage[], + [liveSession?.event_messages] + ) + const totalSteps = actionMessages.length + const [currentStep, setCurrentStep] = useState(0) + const [isLiveUpdate, setIsLiveUpdate] = useState(true) + + useEffect(() => { + setCurrentStep(0) + setIsLiveUpdate(true) + }, [liveSession?.session_id]) + + useEffect(() => { + if (!liveSession?.is_awaiting_turn_action) { + return + } + + setCurrentStep(0) + setIsLiveUpdate(true) + }, [liveSession?.is_awaiting_turn_action]) + + useEffect(() => { + if (isLiveUpdate && totalSteps > 0) { + setCurrentStep(totalSteps) + } + }, [isLiveUpdate, totalSteps]) + + useEffect(() => { + const requestedIndex = getRequestedActionIndex( + actionMessages, + requestedAction + ) + + if (requestedIndex >= 0) { + setCurrentStep(requestedIndex + 1) + setIsLiveUpdate(false) + } + }, [actionMessages, requestedAction, requestedActionToken]) + + const step = + totalSteps > 0 + ? currentStep > 0 + ? Math.min(currentStep, totalSteps) + : totalSteps + : 0 + const hasActionHistory = totalSteps > 0 + const isAwaitingTurnAction = + Boolean(liveSession?.is_awaiting_turn_action) && isLiveUpdate + const selectedAction = + step > 0 ? actionMessages[step - 1]?.action : undefined + const liveCurrentAction = isCoworkBuildPanelActionVisible( + liveSession?.current_action + ) + ? liveSession?.current_action + : undefined + + const currentAction = + (!isAwaitingTurnAction ? selectedAction : undefined) ?? + (!isAwaitingTurnAction ? liveCurrentAction : undefined) + + const currentToolCall = useMemo(() => { + if (!currentAction) { + return undefined + } + + const toolCalls = liveSession?.tool_calls ?? [] + + if (!currentAction?.data.tool_call_id) { + return ( + [...toolCalls] + .reverse() + .find( + (toolCall) => + toolCall.name === currentAction.data.tool_name || + toolCall.display_name === + currentAction.data.tool_display_name + ) ?? toolCalls.at(-1) + ) + } + + return ( + toolCalls.find( + (toolCall) => toolCall.id === currentAction.data.tool_call_id + ) ?? toolCalls.at(-1) + ) + }, [currentAction, liveSession?.tool_calls]) + + const previewPath = getPreviewPath(currentAction) + const previewContent = getPreviewContent(currentAction) + const currentActivities = useMemo(() => { + if (!currentAction) { + return [] + } + + const toolCallId = + currentAction.data.tool_call_id ?? currentToolCall?.id ?? null + + return (liveSession?.activities ?? []).filter((activity) => { + if (toolCallId && activity.tool_call_id === toolCallId) { + return true + } + + return ( + activity.tool_name === currentAction.data.tool_name && + Boolean(activity.tool_name) + ) + }) + }, [currentAction, currentToolCall?.id, liveSession?.activities]) + const searchKeyword = + currentAction?.data.tool_input?.query || + currentAction?.data.tool_input?.queries?.join(', ') + const searchResults = + currentAction && + coworkBuildEventToolGroups.search.has(currentAction.type) && + currentAction.data.result + ? typeof currentAction.data.result === 'string' + ? parseJson(currentAction.data.result) + : currentAction.data.result + : undefined + const browserUrl = + currentAction && + coworkBuildEventToolGroups.browser.has(currentAction.type) + ? currentAction.data.tool_input?.url || + currentAction.data.tool_input?.urls?.[0] + : undefined + const browserRaw = + currentAction && browserUrl !== undefined + ? typeof currentAction.data.result === 'string' + ? currentAction.data.result + : stringifyValue(currentAction.data.result) + : undefined + const fallbackContent = + currentToolCall?.result || currentToolCall?.input || '' + + return { + actionMessages, + totalSteps, + step, + isLiveUpdate, + hasActionHistory, + isAwaitingTurnAction, + currentAction, + currentToolCall, + currentActivities, + fallbackContent, + previewPath, + previewContent, + browserUrl, + browserRaw, + searchKeyword, + searchResults, + setStep: (nextStep, liveUpdate = false) => { + setCurrentStep(nextStep) + setIsLiveUpdate(liveUpdate) + }, + jumpToLatest: () => { + setCurrentStep(totalSteps) + setIsLiveUpdate(true) + } + } +} + +interface CoworkBuildControllerProps { + step: number + totalSteps: number + isLiveUpdate: boolean + onStepChange: (step: number, liveUpdate?: boolean) => void + onJumpToLatest: () => void +} + +export const CoworkBuildController = ({ + step, + totalSteps, + isLiveUpdate, + onStepChange, + onJumpToLatest +}: CoworkBuildControllerProps) => { + if (totalSteps === 0) { + return null + } + + return ( +
+
+
+ +
+ {step} + / + {totalSteps} +
+ +
+ + onStepChange(value[0], value[0] >= totalSteps) + } + max={totalSteps} + step={1} + /> +
+ {isLiveUpdate && step === totalSteps ? ( +
+ Live update +
+ ) : ( + + )} +
+ ) +} + +export const CoworkBuildViewport = ({ + state, + renderers, + renderUnsupportedAction = true, + unsupportedMessage = 'This event does not have a dedicated build renderer yet.', + emptyLabel = 'Cowork build', + emptyTitle = 'Waiting for streaming events' +}: { + state: CoworkBuildState + renderers: CoworkBuildRendererDefinition[] + renderUnsupportedAction?: boolean + unsupportedMessage?: string + emptyLabel?: string + emptyTitle?: string +}) => { + const { + currentAction, + currentToolCall, + fallbackContent, + previewPath, + previewContent, + currentActivities, + browserUrl, + browserRaw, + searchKeyword, + searchResults + } = state + + if (!currentAction) { + return ( +
+
+
+
+ +
+ +
+
+

+ {emptyLabel} +

+

{emptyTitle}

+
+
+
+
+ {fallbackContent ? ( +

+ {fallbackContent} +

+ ) : null} +
+
+ ) + } + + const context: CoworkBuildRendererContext = { + currentAction, + currentToolCall, + currentActivities, + previewPath, + previewContent, + browserUrl, + browserRaw, + searchKeyword, + searchResults + } + + const renderer = renderers.find((entry) => entry.matches(currentAction)) + if (renderer) { + return <>{renderer.render(context)} + } + + if (!renderUnsupportedAction) { + return ( +
+ {unsupportedMessage} +
+ ) + } + + return ( +
+
+ {}} + /> +

+ {unsupportedMessage} +

+ {(currentToolCall?.result || currentToolCall?.input) && ( +
+                        {currentToolCall?.result ?? currentToolCall?.input}
+                    
+ )} +
+
+ ) +} diff --git a/frontend/src/components/cowork/cowork-build/cowork-build.types.ts b/frontend/src/components/cowork/cowork-build/cowork-build.types.ts new file mode 100644 index 000000000..ff6886605 --- /dev/null +++ b/frontend/src/components/cowork/cowork-build/cowork-build.types.ts @@ -0,0 +1,68 @@ +import type { ReactNode } from 'react' +import type { ActionStep, Message } from '@/typings/agent' +import type { + CoworkLiveActivity, + CoworkLiveSessionState, + CoworkLiveToolCall +} from '@/typings/cowork' + +export type CoworkBuildActionMessage = Message & { + action: ActionStep +} + +export type CoworkBuildRendererKey = + | 'terminal' + | 'code' + | 'search' + | 'browser' + | 'desktopTool' + +export type CoworkBuildSearchResult = + | string + | Record + | Record[] + +export interface CoworkBuildRendererContext { + currentAction: ActionStep + currentToolCall?: CoworkLiveToolCall + currentActivities: CoworkLiveActivity[] + previewPath?: string + previewContent: string + browserUrl?: string + browserRaw?: string + searchKeyword?: string + searchResults?: CoworkBuildSearchResult +} + +export interface CoworkBuildRendererDefinition { + key: string + matches: (action: ActionStep) => boolean + render: (context: CoworkBuildRendererContext) => ReactNode +} + +export interface UseCoworkBuildStateOptions { + liveSession?: CoworkLiveSessionState | null + requestedAction?: ActionStep | null + requestedActionToken?: number +} + +export interface CoworkBuildState { + actionMessages: CoworkBuildActionMessage[] + totalSteps: number + step: number + isLiveUpdate: boolean + hasActionHistory: boolean + isAwaitingTurnAction: boolean + currentAction?: ActionStep + currentToolCall?: CoworkLiveToolCall + currentActivities: CoworkLiveActivity[] + fallbackContent: string + previewPath?: string + previewContent: string + browserUrl?: string + browserRaw?: string + searchKeyword?: string + searchResults?: CoworkBuildSearchResult + setStep: (step: number, liveUpdate?: boolean) => void + jumpToLatest: () => void +} diff --git a/frontend/src/components/cowork/cowork-header.tsx b/frontend/src/components/cowork/cowork-header.tsx new file mode 100644 index 000000000..e8abea150 --- /dev/null +++ b/frontend/src/components/cowork/cowork-header.tsx @@ -0,0 +1,39 @@ +import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router' +import ButtonIcon from '@/components/button-icon' +import { Logo } from '@/components/logo' +import { ENABLE_BETA } from '@/constants/features' +import { useIsSageTheme } from '@/hooks/use-is-sage-theme' + +const CoworkHeader = () => { + const { t } = useTranslation() + const navigate = useNavigate() + const isSage = useIsSageTheme() + + return ( +
+ navigate('/')} + /> + +
+ + II-Cowork + +
+
+ ) +} + +export default CoworkHeader diff --git a/frontend/src/components/cowork/cowork-main.tsx b/frontend/src/components/cowork/cowork-main.tsx new file mode 100644 index 000000000..030d0ff14 --- /dev/null +++ b/frontend/src/components/cowork/cowork-main.tsx @@ -0,0 +1,102 @@ +import type { + CoworkChatSessionDetail, + CoworkLiveSessionState +} from '@/typings/cowork' +import type { ActionStep } from '@/typings/agent' +import { Icon } from '@/components/ui/icon' +import { cn } from '@/lib/utils' +import { COWORK_MODES, type CoworkModeId } from './cowork.constants' +import IntelligentFolderMode from './modes/intelligent-folder' + +interface CoworkMainProps { + activeMode: CoworkModeId | null + folderSession: CoworkChatSessionDetail | null + folderLiveSession: CoworkLiveSessionState | null + isFolderSessionLoading: boolean + isFolderChatSending: boolean + onSelectMode: (mode: CoworkModeId) => void + folderModeResetVersion: number + onFolderWorkflowActiveChange: (active: boolean) => void + onFolderSessionCreated: (session: CoworkChatSessionDetail) => void + requestedFolderAction?: ActionStep | null + requestedFolderActionToken?: number +} + +const CoworkMain = ({ + activeMode, + folderSession, + folderLiveSession, + isFolderSessionLoading, + isFolderChatSending, + onSelectMode, + folderModeResetVersion, + onFolderWorkflowActiveChange, + onFolderSessionCreated, + requestedFolderAction = null, + requestedFolderActionToken = 0 +}: CoworkMainProps) => { + if (activeMode === 'intelligent-folder') { + return ( + + ) + } + + return ( +
+
+
+

+ II-Cowork +

+

+ Homepage +

+

+ Choose a mode to enter the Cowork workflow. +

+
+ +
+ {COWORK_MODES.map((mode) => ( + + ))} +
+
+
+ ) +} + +export default CoworkMain diff --git a/frontend/src/components/cowork/cowork-page.tsx b/frontend/src/components/cowork/cowork-page.tsx new file mode 100644 index 000000000..2e9a26785 --- /dev/null +++ b/frontend/src/components/cowork/cowork-page.tsx @@ -0,0 +1,1979 @@ +import { listen } from '@tauri-apps/api/event' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { DesignModeProvider } from '@/components/design-mode' +import RightSidebar from '@/components/right-sidebar' +import { coworkService } from '@/services/cowork.service' +import Sidebar from '@/components/sidebar' +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { SidebarProvider } from '@/components/ui/sidebar' +import { + useAppSelector, + selectAvailableModels, + selectSelectedModel, + selectChatToolSettings, + selectSelectedGitHubRepository +} from '@/state' +import { toast } from 'sonner' +import type { ActionStep, Message } from '@/typings/agent' +import { TOOL } from '@/typings/agent' +import type { + CoworkChatLiveEvent, + CoworkChatMessage, + CoworkChatScope, + CoworkChatSessionDetail, + CoworkChatSessionSummary, + CoworkLiveActivity, + CoworkLiveActivityStatus, + CoworkLiveSessionState, + CoworkLiveToolCall +} from '@/typings/cowork' +import CoworkChatBox from './chat/cowork-chat-box' +import { + buildCoworkTranscriptMessageId, + resolveCoworkTranscriptAnchor +} from './chat/cowork-chatmessage-contract' +import { + inferCoworkToolDisplayName, + isCoworkBuildPanelActionVisible, + normalizeCoworkToolNameForUi, + readCoworkRecord, + readCoworkString +} from './cowork-action-utils' +import CoworkHeader from './cowork-header' +import CoworkMain from './cowork-main' +import { type CoworkModeId } from './cowork.constants' +import CoworkSidebar from './cowork-sidebar' +import CoworkTabs from './cowork-tabs' + +const HOMEPAGE_SCOPE: CoworkChatScope = 'homepage' +const FOLDER_SCOPE: CoworkChatScope = 'intelligent-folder' + +const createScopeRecord = ( + initialValue: T +): Record => ({ + homepage: initialValue, + 'intelligent-folder': initialValue +}) + +const upsertChatSessionSummary = ( + sessions: CoworkChatSessionSummary[], + nextSession: CoworkChatSessionSummary +) => + [ + ...sessions.filter((session) => session.id !== nextSession.id), + nextSession + ].sort( + (left, right) => + new Date(right.updated_at).getTime() - + new Date(left.updated_at).getTime() + ) + +const buildChatSessionSummary = ( + session: CoworkChatSessionDetail +): CoworkChatSessionSummary => ({ + id: session.id, + scope: session.scope, + title: session.title, + preview: session.preview, + updated_at: session.updated_at, + message_count: session.messages.length +}) + +const MAX_LIVE_ACTIVITIES = 40 + +const buildSessionDetailFromSummary = ( + summary: CoworkChatSessionSummary, + current?: CoworkChatSessionDetail | null +): CoworkChatSessionDetail => ({ + id: summary.id, + scope: summary.scope, + title: summary.title, + preview: summary.preview, + updated_at: summary.updated_at, + message_count: summary.message_count, + runtime_kind: current?.runtime_kind, + runtime_session_id: current?.runtime_session_id, + messages: current?.messages ?? [], + runtime_events: current?.runtime_events ?? [], + files: current?.files ?? [], + run_status: current?.run_status ?? 'idle', + folder_tree_pair: current?.folder_tree_pair +}) + +const appendMessageIfMissing = ( + messages: CoworkChatMessage[], + nextMessage: CoworkChatMessage +) => { + if (messages.some((message) => message.id === nextMessage.id)) { + return messages + } + return [...messages, nextMessage] +} + +const stringifyLiveValue = (value: unknown) => { + if (typeof value === 'string') { + return value.trim() || undefined + } + + if (value === null || value === undefined) { + return undefined + } + + try { + const serialized = JSON.stringify(value, null, 2) + return serialized === '{}' || serialized === '[]' + ? undefined + : serialized + } catch { + return String(value) + } +} + +const readString = (record: Record, key: string) => + readCoworkString(record, key) + +const readEventText = (record: Record) => + readString(record, 'text') ?? + readString(record, 'message') ?? + readString(record, 'content') ?? + readString(record, 'delta') + +const mergeStreamingText = (current: string, incoming: string) => { + if (!incoming) { + return current + } + + if (!current) { + return incoming + } + + if (incoming === current) { + return current + } + + if (incoming.startsWith(current)) { + return incoming + } + + if (current.endsWith(incoming)) { + return current + } + + const maxOverlap = Math.min(current.length, incoming.length) + + for (let size = maxOverlap; size > 0; size -= 1) { + if (current.slice(-size) === incoming.slice(0, size)) { + return `${current}${incoming.slice(size)}` + } + } + + return `${current}${incoming}` +} + +const readRecord = (value: unknown): Record | undefined => + readCoworkRecord(value) + +const toTimestamp = (value?: string) => { + if (!value) { + return Date.now() + } + + const timestamp = new Date(value).getTime() + return Number.isNaN(timestamp) ? Date.now() : timestamp +} + +const inferToolDisplayName = (content: Record) => + inferCoworkToolDisplayName({ + toolName: readString(content, 'tool_name'), + displayName: + readString(content, 'display_name') ?? + readString(content, 'tool_display_name'), + toolInput: readRecord(content.tool_input) + }) + +const HIDDEN_TOOL_MESSAGE_TYPES = new Set([ + TOOL.SEQUENTIAL_THINKING, + TOOL.MESSAGE_USER, + TOOL.SUBMIT_PLAN, + TOOL.SUBMIT_PLAN_MODIFICATION_SUGGESTIONS, + TOOL.RETURN_CONTROL_TO_USER +]) + +const buildCoworkActionStep = ( + content: Record, + overrides?: Partial +): ActionStep | undefined => { + const rawToolName = readString(content, 'tool_name') + const toolName = normalizeCoworkToolNameForUi(rawToolName) + + if (!toolName) { + return undefined + } + + return { + type: toolName as TOOL, + data: { + ...(content as Record), + ...overrides, + tool_name: toolName, + tool_display_name: + readString(content, 'display_name') ?? + readString(content, 'tool_display_name') ?? + inferToolDisplayName(content), + raw_tool_name: rawToolName, + tool_logo: readString(content, 'tool_logo'), + tool_input: readRecord(content.tool_input) as + | ActionStep['data']['tool_input'] + | undefined + } as ActionStep['data'] + } +} + +const upsertEventMessage = (messages: Message[], nextMessage: Message) => { + const existingIndex = messages.findIndex( + (message) => message.id === nextMessage.id + ) + + if (existingIndex === -1) { + return [...messages, nextMessage] + } + + const nextMessages = [...messages] + nextMessages[existingIndex] = { + ...nextMessages[existingIndex], + ...nextMessage + } + return nextMessages +} + +const retainCoworkActionMessages = (messages: Message[]) => + messages.filter((message) => Boolean(message.action)) + +const buildTranscriptEventMessage = ({ + kind, + content, + sessionId, + emittedAt, + messageId +}: { + kind: 'thinking' | 'response' + content: string + sessionId: string + emittedAt: string + messageId?: string +}): Message => ({ + id: + messageId ?? + buildCoworkTranscriptMessageId( + kind, + resolveCoworkTranscriptAnchor(emittedAt, sessionId) ?? sessionId + ), + role: 'assistant', + content, + timestamp: toTimestamp(emittedAt), + ...(kind === 'thinking' ? { isThinkMessage: true } : {}) +}) + +const flushTranscriptBuffer = ({ + messages, + kind, + content, + sessionId, + emittedAt, + messageId +}: { + messages: Message[] + kind: 'thinking' | 'response' + content?: string + sessionId: string + emittedAt: string + messageId?: string +}) => { + const normalizedContent = content?.trim() + + if (!normalizedContent) { + return messages + } + + return upsertEventMessage( + messages, + buildTranscriptEventMessage({ + kind, + content: normalizedContent, + sessionId, + emittedAt, + messageId + }) + ) +} + +const buildToolEventMessageId = ( + sessionId: string, + toolCallId: string | undefined, + toolName: string, + emittedAt: string +) => + toolCallId + ? `${sessionId}:tool:${toolCallId}` + : `${sessionId}:tool:${toolName}:${emittedAt}` + +const syncActionEventMessage = ( + messages: Message[], + action: ActionStep, + sessionId: string, + emittedAt: string +) => { + const toolCallId = action.data.tool_call_id + const nextMessageId = buildToolEventMessageId( + sessionId, + toolCallId, + action.type, + emittedAt + ) + + let matchIndex = -1 + + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index] + if (!message.action) { + continue + } + + if ( + toolCallId && + message.action.data.tool_call_id && + message.action.data.tool_call_id === toolCallId + ) { + matchIndex = index + break + } + + if ( + !toolCallId && + message.action.type === action.type && + !message.action.data.isResult + ) { + matchIndex = index + break + } + } + + if (matchIndex === -1) { + return { + messages: upsertEventMessage(messages, { + id: nextMessageId, + role: 'assistant', + action, + timestamp: toTimestamp(emittedAt) + }), + currentAction: action + } + } + + const nextMessages = [...messages] + nextMessages[matchIndex] = { + ...nextMessages[matchIndex], + action + } + + return { + messages: nextMessages, + currentAction: action + } +} + +const inferToolStatus = ( + remoteEventType: string, + isError = false +): CoworkLiveActivityStatus => { + if (isError || remoteEventType === 'error') { + return 'error' + } + if ( + remoteEventType === 'tool_confirmation' || + remoteEventType === 'waiting_for_user_input' + ) { + return 'waiting' + } + if ( + remoteEventType === 'tool_result' || + remoteEventType === 'complete' || + remoteEventType === 'stream_complete' || + remoteEventType === 'sub_agent_complete' + ) { + return 'completed' + } + return 'running' +} + +const appendActivity = ( + activities: CoworkLiveActivity[], + activity: CoworkLiveActivity +) => [...activities, activity].slice(-MAX_LIVE_ACTIVITIES) + +const appendRuntimeEventIfMissing = ( + events: CoworkChatSessionDetail['runtime_events'], + nextEvent: CoworkChatSessionDetail['runtime_events'][number] +) => { + const alreadyExists = events.some((event) => { + if (event.runtime_event_id && nextEvent.runtime_event_id) { + return ( + event.runtime_event_type === nextEvent.runtime_event_type && + event.runtime_event_id === nextEvent.runtime_event_id + ) + } + + return ( + event.runtime_event_type === nextEvent.runtime_event_type && + event.runtime_created_at === nextEvent.runtime_created_at && + event.emitted_at === nextEvent.emitted_at + ) + }) + + if (alreadyExists) { + return events + } + + return [...events, nextEvent].sort((left, right) => { + const leftTime = left.runtime_created_at ?? left.emitted_at + const rightTime = right.runtime_created_at ?? right.emitted_at + + if (leftTime === rightTime) { + return `${left.runtime_event_type}:${left.runtime_event_id ?? ''}`.localeCompare( + `${right.runtime_event_type}:${right.runtime_event_id ?? ''}` + ) + } + + return leftTime.localeCompare(rightTime) + }) +} + +const reduceCoworkLiveEvent = ( + current: CoworkLiveSessionState | undefined, + event: CoworkChatLiveEvent +): CoworkLiveSessionState | undefined => { + if (event.type !== 'runtime.event') { + return current + } + + const content = event.content ?? {} + const nextState: CoworkLiveSessionState = current ?? { + session_id: event.session_id, + scope: event.scope, + thinking: '', + response: '', + is_awaiting_turn_action: false, + tool_calls: [], + activities: [], + event_messages: [] + } + + const toolCallId = readString(content, 'tool_call_id') + const toolDisplayName = inferToolDisplayName(content) + const skillName = + typeof content.tool_input === 'object' && + content.tool_input !== null && + 'skill' in content.tool_input && + typeof content.tool_input.skill === 'string' + ? content.tool_input.skill + : undefined + const agentName = readString(content, 'agent_name') + const emittedAt = event.emitted_at + const resolveTranscriptId = ( + kind: 'thinking' | 'response', + currentMessageId: string | undefined + ) => + currentMessageId ?? + buildCoworkTranscriptMessageId( + kind, + resolveCoworkTranscriptAnchor( + event.runtime_event_id, + event.runtime_created_at, + emittedAt + ) ?? emittedAt + ) + const resolveFinalTranscriptId = (kind: 'thinking' | 'response') => + buildCoworkTranscriptMessageId( + kind, + resolveCoworkTranscriptAnchor( + event.runtime_event_id, + event.runtime_created_at, + emittedAt + ) ?? emittedAt + ) + + switch (event.runtime_event_type) { + case 'reasoning_delta': + case 'agent_thinking_delta': { + const delta = readEventText(content) + if (!delta) return nextState + return { + ...nextState, + thinking: mergeStreamingText(nextState.thinking, delta), + thinking_message_id: resolveTranscriptId( + 'thinking', + nextState.thinking_message_id + ), + thinking_started_at: nextState.thinking_started_at ?? emittedAt, + latest_runtime_event_type: event.runtime_event_type, + last_event_at: emittedAt + } + } + case 'reasoning': + case 'agent_thinking': { + const text = readEventText(content) + const finalizedThinking = text + ? mergeStreamingText(nextState.thinking, text) + : nextState.thinking + const finalizedThinkingId = text + ? resolveFinalTranscriptId('thinking') + : nextState.thinking_message_id + return { + ...nextState, + thinking: finalizedThinking, + thinking_message_id: finalizedThinkingId, + thinking_started_at: nextState.thinking_started_at ?? emittedAt, + latest_runtime_event_type: event.runtime_event_type, + last_event_at: emittedAt + } + } + case 'agent_response_delta': { + const delta = readEventText(content) + if (!delta) return nextState + const flushedThinkingMessages = flushTranscriptBuffer({ + messages: nextState.event_messages, + kind: 'thinking', + content: nextState.thinking, + sessionId: event.session_id, + emittedAt, + messageId: nextState.thinking_message_id + }) + return { + ...nextState, + thinking: '', + thinking_message_id: undefined, + thinking_started_at: undefined, + event_messages: flushedThinkingMessages, + response: mergeStreamingText(nextState.response, delta), + response_message_id: resolveTranscriptId( + 'response', + nextState.response_message_id + ), + response_started_at: nextState.response_started_at ?? emittedAt, + latest_runtime_event_type: event.runtime_event_type, + last_event_at: emittedAt + } + } + case 'agent_response': { + const text = readEventText(content) + const finalizedResponse = text + ? mergeStreamingText(nextState.response, text) + : nextState.response + const finalizedResponseId = text + ? resolveFinalTranscriptId('response') + : nextState.response_message_id + return { + ...nextState, + response: finalizedResponse, + response_message_id: finalizedResponseId, + response_started_at: nextState.response_started_at ?? emittedAt, + latest_runtime_event_type: event.runtime_event_type, + last_event_at: emittedAt + } + } + case 'tool_call': { + const input = stringifyLiveValue(content.tool_input) + const nextToolCall: CoworkLiveToolCall = { + id: toolCallId ?? `${event.session_id}:${toolDisplayName}`, + name: readString(content, 'tool_name') ?? toolDisplayName, + display_name: toolDisplayName, + input, + status: 'running', + logo: readString(content, 'tool_logo'), + skill_name: skillName, + agent_name: agentName + } + + const toolCalls = [ + ...nextState.tool_calls.filter( + (tool) => tool.id !== nextToolCall.id + ), + nextToolCall + ] + const baseEventMessages = flushTranscriptBuffer({ + messages: nextState.event_messages, + kind: 'thinking', + content: nextState.thinking, + sessionId: event.session_id, + emittedAt, + messageId: nextState.thinking_message_id + }) + const action = buildCoworkActionStep(content) + const actionSync = + action && !HIDDEN_TOOL_MESSAGE_TYPES.has(action.type) + ? syncActionEventMessage( + baseEventMessages, + action, + event.session_id, + emittedAt + ) + : null + + return { + ...nextState, + tool_calls: toolCalls, + thinking: '', + thinking_message_id: undefined, + thinking_started_at: undefined, + is_awaiting_turn_action: actionSync + ? false + : nextState.is_awaiting_turn_action, + event_messages: actionSync?.messages ?? baseEventMessages, + current_action: + actionSync?.currentAction ?? nextState.current_action, + activities: appendActivity(nextState.activities, { + id: `${event.session_id}:${emittedAt}:tool-call:${nextToolCall.id}`, + runtime_event_type: event.runtime_event_type, + title: toolDisplayName, + detail: input, + timestamp: emittedAt, + status: 'running', + tool_call_id: nextToolCall.id, + tool_name: nextToolCall.name, + skill_name: skillName, + agent_name: agentName + }), + latest_runtime_event_type: event.runtime_event_type, + last_event_at: emittedAt + } + } + case 'tool_result': { + const result = stringifyLiveValue(content.result) + const status = inferToolStatus( + event.runtime_event_type, + content.is_error === true + ) + const nextToolId = + toolCallId ?? `${event.session_id}:${toolDisplayName}` + const existingToolCall = nextState.tool_calls.find( + (tool) => tool.id === nextToolId + ) + + const toolCalls = [ + ...nextState.tool_calls.filter( + (tool) => tool.id !== nextToolId + ), + { + id: nextToolId, + name: + readString(content, 'tool_name') ?? + existingToolCall?.name ?? + toolDisplayName, + display_name: toolDisplayName, + input: + existingToolCall?.input ?? + stringifyLiveValue(content.tool_input), + result, + status, + logo: + readString(content, 'tool_logo') ?? + existingToolCall?.logo, + skill_name: skillName ?? existingToolCall?.skill_name, + agent_name: agentName ?? existingToolCall?.agent_name + } + ] + const action = buildCoworkActionStep(content, { + result: content.result as ActionStep['data']['result'], + isResult: true + }) + const actionSync = + action && !HIDDEN_TOOL_MESSAGE_TYPES.has(action.type) + ? syncActionEventMessage( + nextState.event_messages, + action, + event.session_id, + emittedAt + ) + : null + + return { + ...nextState, + tool_calls: toolCalls, + is_awaiting_turn_action: actionSync + ? false + : nextState.is_awaiting_turn_action, + event_messages: + actionSync?.messages ?? nextState.event_messages, + current_action: + actionSync?.currentAction ?? nextState.current_action, + activities: appendActivity(nextState.activities, { + id: `${event.session_id}:${emittedAt}:tool-result:${nextToolId}`, + runtime_event_type: event.runtime_event_type, + title: `${toolDisplayName} finished`, + detail: result, + timestamp: emittedAt, + status, + tool_call_id: nextToolId, + tool_name: + readString(content, 'tool_name') ?? toolDisplayName, + skill_name: skillName, + agent_name: agentName + }), + latest_runtime_event_type: event.runtime_event_type, + last_event_at: emittedAt + } + } + case 'tool_confirmation': + case 'waiting_for_user_input': + case 'reasoning_start': + case 'agent_thinking_start': + case 'sub_agent_complete': + case 'processing': + case 'complete': + case 'stream_complete': + case 'system': + case 'error': + case 'agent_response_interrupted': + case 'model_compact': { + const flushedThinkingMessages = flushTranscriptBuffer({ + messages: nextState.event_messages, + kind: 'thinking', + content: nextState.thinking, + sessionId: event.session_id, + emittedAt, + messageId: nextState.thinking_message_id + }) + const finalizedResponseContent = + event.runtime_event_type === 'complete' || + event.runtime_event_type === 'stream_complete' || + event.runtime_event_type === 'sub_agent_complete' + ? (readEventText(content) ?? nextState.response) + : nextState.response + const flushedTranscriptMessages = flushTranscriptBuffer({ + messages: flushedThinkingMessages, + kind: 'response', + content: finalizedResponseContent, + sessionId: event.session_id, + emittedAt, + messageId: nextState.response_message_id + }) + const detail = + readEventText(content) ?? + stringifyLiveValue(content.result) ?? + stringifyLiveValue(content.summary) + const titleMap: Record = { + tool_confirmation: 'Waiting for confirmation', + waiting_for_user_input: 'Waiting for input', + reasoning_start: 'Thinking', + agent_thinking_start: 'Thinking', + sub_agent_complete: agentName + ? `Sub-agent finished: ${agentName}` + : 'Sub-agent finished', + processing: 'Processing', + complete: 'Run completed', + stream_complete: 'Stream completed', + system: 'System event', + error: 'Run error', + agent_response_interrupted: 'Run interrupted', + model_compact: 'Session compacted' + } + + return { + ...nextState, + thinking: '', + response: '', + thinking_message_id: undefined, + response_message_id: undefined, + thinking_started_at: undefined, + response_started_at: undefined, + event_messages: flushedTranscriptMessages, + activities: appendActivity(nextState.activities, { + id: `${event.session_id}:${emittedAt}:${event.runtime_event_type}`, + runtime_event_type: event.runtime_event_type, + title: titleMap[event.runtime_event_type] ?? 'Activity', + detail, + timestamp: emittedAt, + status: inferToolStatus( + event.runtime_event_type, + event.runtime_event_type === 'error' + ), + agent_name: agentName + }), + latest_runtime_event_type: event.runtime_event_type, + last_event_at: emittedAt + } + } + default: + return { + ...nextState, + latest_runtime_event_type: event.runtime_event_type, + last_event_at: emittedAt + } + } +} + +const replayPersistedLiveSession = ( + session: CoworkChatSessionDetail | null | undefined +): CoworkLiveSessionState | null => { + if (!session || session.runtime_events.length === 0) { + return null + } + + const replayedState = session.runtime_events.reduce< + CoworkLiveSessionState | undefined + >((state, event) => reduceCoworkLiveEvent(state, event), undefined) + + if (!replayedState) { + return null + } + + return { + ...replayedState, + event_messages: + session.messages.length > 0 + ? retainCoworkActionMessages(replayedState.event_messages) + : replayedState.event_messages, + thinking: '', + response: '', + thinking_message_id: undefined, + response_message_id: undefined, + thinking_started_at: undefined, + response_started_at: undefined + } +} + +const CoworkPage = () => { + const [activeMode, setActiveMode] = useState(null) + const [isSidebarOpen, setIsSidebarOpen] = useState(false) + const [isChatSessionsBoardOpen, setIsChatSessionsBoardOpen] = + useState(false) + const [folderModeResetVersion, setFolderModeResetVersion] = useState(0) + const [isSessionsBoardOpen, setIsSessionsBoardOpen] = useState(false) + const [isFolderWorkflowActive, setIsFolderWorkflowActive] = useState(false) + const [pendingDeleteSession, setPendingDeleteSession] = + useState(null) + const [isDeletingSession, setIsDeletingSession] = useState(false) + const [chatSessionsByScope, setChatSessionsByScope] = useState< + Record + >(() => createScopeRecord([])) + const [activeChatSessionIds, setActiveChatSessionIds] = useState< + Record + >(() => createScopeRecord(null)) + const [activeChatSessionsByScope, setActiveChatSessionsByScope] = useState< + Record + >(() => createScopeRecord(null)) + const [isChatSessionsLoadingByScope, setIsChatSessionsLoadingByScope] = + useState>(() => + createScopeRecord(true) + ) + const [isChatSessionLoadingByScope, setIsChatSessionLoadingByScope] = + useState>(() => + createScopeRecord(false) + ) + const [isChatSendingByScope, setIsChatSendingByScope] = useState< + Record + >(() => createScopeRecord(false)) + const [liveSessionsById, setLiveSessionsById] = useState< + Record + >({}) + const [requestedFolderAction, setRequestedFolderAction] = + useState(null) + const [requestedFolderActionToken, setRequestedFolderActionToken] = + useState(0) + const recoveringSessionIdsRef = useRef>(new Set()) + const suppressedStreamingSessionIdsRef = useRef>(new Set()) + const availableModels = useAppSelector(selectAvailableModels) + const selectedModelId = useAppSelector(selectSelectedModel) + const chatToolSettings = useAppSelector(selectChatToolSettings) + const selectedGitHubRepository = useAppSelector( + selectSelectedGitHubRepository + ) + + const currentChatScope: CoworkChatScope = + activeMode === 'intelligent-folder' ? FOLDER_SCOPE : HOMEPAGE_SCOPE + + const currentActiveChatSession = activeChatSessionsByScope[currentChatScope] + const replayedCurrentLiveSession = useMemo( + () => replayPersistedLiveSession(currentActiveChatSession), + [currentActiveChatSession] + ) + const currentLiveSession = currentActiveChatSession + ? (liveSessionsById[currentActiveChatSession.id] ?? + replayedCurrentLiveSession ?? + null) + : null + const isCurrentChatSessionLoading = + isChatSessionLoadingByScope[currentChatScope] + const isCurrentChatSending = isChatSendingByScope[currentChatScope] + const isCurrentChatInputLocked = + activeMode === 'intelligent-folder' && !isFolderWorkflowActive + + const homepageChatSessions = chatSessionsByScope[HOMEPAGE_SCOPE] + const homepageActiveChatSessionId = activeChatSessionIds[HOMEPAGE_SCOPE] + const folderChatSessions = chatSessionsByScope[FOLDER_SCOPE] + const folderActiveChatSessionId = activeChatSessionIds[FOLDER_SCOPE] + const folderActiveChatSession = activeChatSessionsByScope[FOLDER_SCOPE] + const replayedFolderLiveSession = useMemo( + () => replayPersistedLiveSession(folderActiveChatSession), + [folderActiveChatSession] + ) + const folderLiveSession = folderActiveChatSession + ? (liveSessionsById[folderActiveChatSession.id] ?? + replayedFolderLiveSession ?? + null) + : null + const isFolderSessionLoading = isChatSessionLoadingByScope[FOLDER_SCOPE] + + const resetFolderSessionState = useCallback(() => { + setActiveChatSessionIds((prev) => ({ + ...prev, + [FOLDER_SCOPE]: null + })) + setActiveChatSessionsByScope((prev) => ({ + ...prev, + [FOLDER_SCOPE]: null + })) + setRequestedFolderAction(null) + setRequestedFolderActionToken(0) + }, []) + + const setScopeLoading = useCallback( + (scope: CoworkChatScope, isLoading: boolean) => { + setIsChatSessionsLoadingByScope((prev) => ({ + ...prev, + [scope]: isLoading + })) + }, + [] + ) + + const setScopeSessionLoading = useCallback( + (scope: CoworkChatScope, isLoading: boolean) => { + setIsChatSessionLoadingByScope((prev) => ({ + ...prev, + [scope]: isLoading + })) + }, + [] + ) + + const setScopeSending = useCallback( + (scope: CoworkChatScope, isSending: boolean) => { + setIsChatSendingByScope((prev) => ({ + ...prev, + [scope]: isSending + })) + }, + [] + ) + + const prepareLiveRunState = useCallback( + ( + sessionId: string | null | undefined, + session?: CoworkChatSessionDetail | null + ) => { + if (!sessionId) { + return + } + + setLiveSessionsById((prev) => { + const current = prev[sessionId] + const replayed = current + ? null + : replayPersistedLiveSession(session) + const baseline: CoworkLiveSessionState = current ?? + replayed ?? { + session_id: sessionId, + scope: session?.scope ?? currentChatScope, + thinking: '', + response: '', + is_awaiting_turn_action: false, + tool_calls: [], + activities: [], + event_messages: [] + } + + return { + ...prev, + [sessionId]: { + ...baseline, + thinking: '', + response: '', + is_awaiting_turn_action: true, + event_messages: retainCoworkActionMessages( + baseline.event_messages + ), + thinking_message_id: undefined, + response_message_id: undefined, + thinking_started_at: undefined, + response_started_at: undefined, + tool_calls: [], + current_action: undefined + } + } + }) + }, + [currentChatScope] + ) + + const finalizeLiveRunState = useCallback( + (sessionId: string | null | undefined) => { + if (!sessionId) { + return + } + + setLiveSessionsById((prev) => { + const current = prev[sessionId] + if (!current) { + return prev + } + + return { + ...prev, + [sessionId]: { + ...current, + thinking: '', + response: '', + event_messages: retainCoworkActionMessages( + current.event_messages + ), + is_awaiting_turn_action: false, + thinking_message_id: undefined, + response_message_id: undefined, + thinking_started_at: undefined, + response_started_at: undefined + } + } + }) + }, + [] + ) + + const removeLiveSessionState = useCallback( + (sessionId: string | null | undefined) => { + if (!sessionId) { + return + } + + setLiveSessionsById((prev) => { + if (!prev[sessionId]) { + return prev + } + + const next = { ...prev } + delete next[sessionId] + return next + }) + }, + [] + ) + + const handleStopCurrentChatSession = useCallback(async () => { + const activeSession = currentActiveChatSession + + if (!activeSession) { + return + } + + if ( + activeSession.run_status !== 'thinking' && + activeSession.run_status !== 'waiting_for_input' + ) { + return + } + + try { + suppressedStreamingSessionIdsRef.current.add(activeSession.id) + const stoppedSession = await coworkService.stopChatSession( + activeSession.id, + activeSession.scope + ) + recoveringSessionIdsRef.current.delete(stoppedSession.id) + removeLiveSessionState(stoppedSession.id) + setActiveChatSessionsByScope((prev) => ({ + ...prev, + [stoppedSession.scope]: stoppedSession + })) + setChatSessionsByScope((prev) => ({ + ...prev, + [stoppedSession.scope]: upsertChatSessionSummary( + prev[stoppedSession.scope], + buildChatSessionSummary(stoppedSession) + ) + })) + } catch (error) { + console.error('Failed to stop Cowork session', error) + suppressedStreamingSessionIdsRef.current.delete(activeSession.id) + toast.error( + error instanceof Error + ? error.message + : 'Unable to stop the Cowork session right now.' + ) + } + }, [currentActiveChatSession, removeLiveSessionState]) + + useEffect(() => { + const session = currentActiveChatSession + if (!session) { + return + } + + const hasActiveLiveStream = Boolean(liveSessionsById[session.id]) + + if ( + hasActiveLiveStream || + isCurrentChatSending || + (session.run_status !== 'thinking' && + session.run_status !== 'waiting_for_input') + ) { + recoveringSessionIdsRef.current.delete(session.id) + return + } + + if (recoveringSessionIdsRef.current.has(session.id)) { + return + } + + recoveringSessionIdsRef.current.add(session.id) + void handleStopCurrentChatSession() + }, [ + currentActiveChatSession, + handleStopCurrentChatSession, + isCurrentChatSending, + liveSessionsById + ]) + + useEffect(() => { + let unlisten: (() => void) | undefined + + void listen('cowork://stream', ({ payload }) => { + if (!payload) { + return + } + + const eventSessionId = + payload.type === 'session.created' || + payload.type === 'session.updated' + ? payload.session.id + : 'session_id' in payload + ? payload.session_id + : undefined + + if ( + eventSessionId && + suppressedStreamingSessionIdsRef.current.has(eventSessionId) + ) { + return + } + + if ( + payload.type === 'session.created' || + payload.type === 'session.updated' + ) { + const session = payload.session + const shouldActivateSession = payload.type === 'session.created' + setChatSessionsByScope((prev) => ({ + ...prev, + [session.scope]: upsertChatSessionSummary( + prev[session.scope], + session + ) + })) + if (shouldActivateSession) { + setActiveChatSessionIds((prev) => ({ + ...prev, + [session.scope]: session.id + })) + } + setActiveChatSessionsByScope((prev) => { + const currentSession = prev[session.scope] + const shouldMerge = + shouldActivateSession || + currentSession?.id === session.id + + if (!shouldMerge) { + return prev + } + + return { + ...prev, + [session.scope]: buildSessionDetailFromSummary( + session, + currentSession + ) + } + }) + return + } + + if (payload.type === 'message.created') { + setActiveChatSessionsByScope((prev) => { + const currentSession = prev[payload.scope] + if ( + !currentSession || + currentSession.id !== payload.session_id + ) { + return prev + } + + const nextSession = { + ...currentSession, + messages: appendMessageIfMissing( + currentSession.messages, + payload.message + ), + message_count: + currentSession.messages.length + + (currentSession.messages.some( + (message) => message.id === payload.message.id + ) + ? 0 + : 1), + updated_at: payload.message.created_at, + preview: + payload.message.role === 'assistant' + ? payload.message.content + : currentSession.preview + } + + return { + ...prev, + [payload.scope]: nextSession + } + }) + return + } + + if (payload.type === 'files.updated') { + setActiveChatSessionsByScope((prev) => { + const currentSession = prev[payload.scope] + if ( + !currentSession || + currentSession.id !== payload.session_id + ) { + return prev + } + + return { + ...prev, + [payload.scope]: { + ...currentSession, + files: payload.files + } + } + }) + return + } + + if (payload.type === 'status.updated') { + setActiveChatSessionsByScope((prev) => { + const currentSession = prev[payload.scope] + if ( + !currentSession || + currentSession.id !== payload.session_id + ) { + return prev + } + + return { + ...prev, + [payload.scope]: { + ...currentSession, + run_status: payload.status + } + } + }) + } + + if (payload.type === 'runtime.event') { + setActiveChatSessionsByScope((prev) => { + const currentSession = prev[payload.scope] + if ( + !currentSession || + currentSession.id !== payload.session_id + ) { + return prev + } + + return { + ...prev, + [payload.scope]: { + ...currentSession, + runtime_events: appendRuntimeEventIfMissing( + currentSession.runtime_events, + payload + ), + updated_at: payload.emitted_at + } + } + }) + setLiveSessionsById((prev) => { + const nextState = reduceCoworkLiveEvent( + prev[payload.session_id], + payload + ) + if (!nextState) { + return prev + } + + return { + ...prev, + [payload.session_id]: nextState + } + }) + } + }).then((dispose) => { + unlisten = dispose + }) + + return () => { + unlisten?.() + } + }, []) + + const refreshChatSessions = useCallback( + async (scope: CoworkChatScope) => { + setScopeLoading(scope, true) + try { + const sessions = await coworkService.getChatSessions(scope) + setChatSessionsByScope((prev) => ({ + ...prev, + [scope]: sessions + })) + } finally { + setScopeLoading(scope, false) + } + }, + [setScopeLoading] + ) + + const loadChatSession = useCallback( + async (sessionId: string, scope: CoworkChatScope) => { + setScopeSessionLoading(scope, true) + + try { + let session = await coworkService.getChatSession( + sessionId, + scope + ) + if ( + session.run_status === 'thinking' || + session.run_status === 'waiting_for_input' + ) { + session = await coworkService.stopChatSession( + session.id, + session.scope + ) + } + setActiveChatSessionIds((prev) => ({ + ...prev, + [session.scope]: session.id + })) + setActiveChatSessionsByScope((prev) => ({ + ...prev, + [session.scope]: session + })) + setChatSessionsByScope((prev) => ({ + ...prev, + [session.scope]: upsertChatSessionSummary( + prev[session.scope], + { + id: session.id, + scope: session.scope, + title: session.title, + preview: session.preview, + updated_at: session.updated_at, + message_count: session.messages.length + } + ) + })) + } finally { + setScopeSessionLoading(scope, false) + } + }, + [setScopeSessionLoading] + ) + + useEffect(() => { + void Promise.all([ + refreshChatSessions(HOMEPAGE_SCOPE), + refreshChatSessions(FOLDER_SCOPE) + ]) + }, [refreshChatSessions]) + + const handleGoHomepage = () => { + setActiveMode(null) + setIsSidebarOpen(false) + setIsChatSessionsBoardOpen(false) + setIsSessionsBoardOpen(false) + setIsFolderWorkflowActive(false) + } + + const handleSelectMode = (mode: CoworkModeId) => { + setActiveMode(mode) + setIsChatSessionsBoardOpen(false) + setIsSessionsBoardOpen(false) + + if (mode === 'intelligent-folder') { + resetFolderSessionState() + setFolderModeResetVersion((prev) => prev + 1) + setIsFolderWorkflowActive(false) + setRequestedFolderAction(null) + setRequestedFolderActionToken(0) + } + } + + const handleChatSessionsBoardOpenChange = (open: boolean) => { + setIsChatSessionsBoardOpen(open) + if (open) { + setIsSessionsBoardOpen(false) + } + } + + const handleSessionsBoardOpenChange = (open: boolean) => { + setIsSessionsBoardOpen(open) + } + + const handleFolderWorkflowActiveChange = (active: boolean) => { + setIsFolderWorkflowActive(active) + } + + const handleOpenModeFirstPage = () => { + if (activeMode === 'intelligent-folder') { + resetFolderSessionState() + setFolderModeResetVersion((prev) => prev + 1) + setIsFolderWorkflowActive(false) + } + + setIsChatSessionsBoardOpen(false) + setIsSessionsBoardOpen(false) + } + + const handleSelectSession = async (sessionId: string) => { + setIsChatSessionsBoardOpen(false) + setIsSessionsBoardOpen(false) + + if (sessionId !== activeChatSessionIds[FOLDER_SCOPE]) { + await loadChatSession(sessionId, FOLDER_SCOPE) + } + } + + const handleRenameSession = useCallback( + async (sessionId: string) => { + const currentSummary = chatSessionsByScope[FOLDER_SCOPE].find( + (session) => session.id === sessionId + ) + const nextTitle = window.prompt( + 'Rename session', + currentSummary?.title ?? '' + ) + + if (!nextTitle || !nextTitle.trim()) { + return + } + + const renamedSession = await coworkService.renameFolderSession( + sessionId, + nextTitle + ) + + setChatSessionsByScope((prev) => ({ + ...prev, + [FOLDER_SCOPE]: upsertChatSessionSummary( + prev[FOLDER_SCOPE], + buildChatSessionSummary(renamedSession) + ) + })) + setActiveChatSessionsByScope((prev) => ({ + ...prev, + [FOLDER_SCOPE]: + prev[FOLDER_SCOPE]?.id === renamedSession.id + ? renamedSession + : prev[FOLDER_SCOPE] + })) + }, + [chatSessionsByScope] + ) + + const handleRenameChatSession = useCallback( + async (sessionId: string) => { + const currentSummary = chatSessionsByScope[HOMEPAGE_SCOPE].find( + (session) => session.id === sessionId + ) + const nextTitle = window.prompt( + 'Rename chat session', + currentSummary?.title ?? '' + ) + + if (!nextTitle || !nextTitle.trim()) { + return + } + + const renamedSession = + await coworkService.renameHomepageChatSession( + sessionId, + nextTitle + ) + + setChatSessionsByScope((prev) => ({ + ...prev, + [HOMEPAGE_SCOPE]: upsertChatSessionSummary( + prev[HOMEPAGE_SCOPE], + buildChatSessionSummary(renamedSession) + ) + })) + setActiveChatSessionsByScope((prev) => ({ + ...prev, + [HOMEPAGE_SCOPE]: + prev[HOMEPAGE_SCOPE]?.id === renamedSession.id + ? renamedSession + : prev[HOMEPAGE_SCOPE] + })) + }, + [chatSessionsByScope] + ) + + const handleDeleteSession = useCallback( + (sessionId: string) => { + const currentSummary = chatSessionsByScope[FOLDER_SCOPE].find( + (session) => session.id === sessionId + ) + + setPendingDeleteSession( + currentSummary ?? { + id: sessionId, + scope: FOLDER_SCOPE, + title: sessionId, + preview: '', + updated_at: '', + message_count: 0 + } + ) + }, + [chatSessionsByScope] + ) + + const handleDeleteChatSession = useCallback( + (sessionId: string) => { + const currentSummary = chatSessionsByScope[HOMEPAGE_SCOPE].find( + (session) => session.id === sessionId + ) + + setPendingDeleteSession( + currentSummary ?? { + id: sessionId, + scope: HOMEPAGE_SCOPE, + title: sessionId, + preview: '', + updated_at: '', + message_count: 0 + } + ) + }, + [chatSessionsByScope] + ) + + const handleConfirmDeleteSession = useCallback(async () => { + if (!pendingDeleteSession || isDeletingSession) { + return + } + + const sessionId = pendingDeleteSession.id + const sessionScope = pendingDeleteSession.scope + setIsDeletingSession(true) + + try { + if (sessionScope === HOMEPAGE_SCOPE) { + await coworkService.deleteHomepageChatSession(sessionId) + } else { + await coworkService.deleteFolderSession(sessionId) + } + + setChatSessionsByScope((prev) => ({ + ...prev, + [sessionScope]: prev[sessionScope].filter( + (session) => session.id !== sessionId + ) + })) + + if (sessionScope === HOMEPAGE_SCOPE) { + if (activeChatSessionIds[HOMEPAGE_SCOPE] === sessionId) { + setActiveChatSessionIds((prev) => ({ + ...prev, + [HOMEPAGE_SCOPE]: null + })) + setActiveChatSessionsByScope((prev) => ({ + ...prev, + [HOMEPAGE_SCOPE]: null + })) + } + } else if (activeChatSessionIds[FOLDER_SCOPE] === sessionId) { + resetFolderSessionState() + setFolderModeResetVersion((prev) => prev + 1) + setIsFolderWorkflowActive(false) + } + + removeLiveSessionState(sessionId) + setPendingDeleteSession(null) + } finally { + setIsDeletingSession(false) + } + }, [ + activeChatSessionIds, + isDeletingSession, + pendingDeleteSession, + removeLiveSessionState, + resetFolderSessionState + ]) + + const handleDeleteDialogOpenChange = useCallback( + (open: boolean) => { + if (!open && !isDeletingSession) { + setPendingDeleteSession(null) + } + }, + [isDeletingSession] + ) + + const handleFolderSessionCreated = useCallback( + (session: CoworkChatSessionDetail) => { + setRequestedFolderAction(null) + setRequestedFolderActionToken((prev) => prev + 1) + setChatSessionsByScope((prev) => ({ + ...prev, + [FOLDER_SCOPE]: upsertChatSessionSummary( + prev[FOLDER_SCOPE], + buildChatSessionSummary(session) + ) + })) + setActiveChatSessionIds((prev) => ({ + ...prev, + [FOLDER_SCOPE]: session.id + })) + setActiveChatSessionsByScope((prev) => ({ + ...prev, + [FOLDER_SCOPE]: session + })) + }, + [] + ) + + const handleSelectCoworkAction = useCallback( + (action: ActionStep) => { + if (currentChatScope !== FOLDER_SCOPE) { + return + } + + if (!isCoworkBuildPanelActionVisible(action)) { + return + } + + setRequestedFolderAction(action) + setRequestedFolderActionToken((prev) => prev + 1) + }, + [currentChatScope] + ) + + const handleSelectChatSession = async (sessionId: string) => { + setActiveMode(null) + setIsChatSessionsBoardOpen(false) + setIsSessionsBoardOpen(false) + + if (sessionId !== homepageActiveChatSessionId) { + await loadChatSession(sessionId, HOMEPAGE_SCOPE) + } + } + + const handleNewChatSession = () => { + setActiveMode(null) + setIsChatSessionsBoardOpen(false) + setIsSessionsBoardOpen(false) + setActiveChatSessionIds((prev) => ({ + ...prev, + [HOMEPAGE_SCOPE]: null + })) + setActiveChatSessionsByScope((prev) => ({ + ...prev, + [HOMEPAGE_SCOPE]: null + })) + } + + const handleSendChatMessage = useCallback( + async (content: string) => { + const scope = currentChatScope + const currentSessionId = activeChatSessionIds[scope] + const currentSession = activeChatSessionsByScope[scope] + const selectedModel = + availableModels.find((model) => model.id === selectedModelId) ?? + availableModels[0] + + if (!selectedModel) { + toast.error( + 'No AI model is configured. Please add a model in settings first.' + ) + return + } + + if (scope === FOLDER_SCOPE) { + setRequestedFolderAction(null) + setRequestedFolderActionToken((prev) => prev + 1) + } + + setScopeSending(scope, true) + prepareLiveRunState(currentSessionId, currentSession) + + try { + const response = await coworkService.sendChatMessage({ + session_id: currentSessionId, + runtime_kind: currentSession?.runtime_kind ?? 'remote', + scope, + content, + model_id: selectedModel.id, + tools: chatToolSettings, + github_repository: selectedGitHubRepository + }) + + let latestSummary: CoworkChatSessionSummary | null = null + + for (const event of response.events) { + if ( + event.type === 'session.created' || + event.type === 'session.updated' + ) { + latestSummary = event.session + } + } + + const hydratedSession = await coworkService.getChatSession( + response.session_id, + scope + ) + const isSuppressed = + suppressedStreamingSessionIdsRef.current.has( + hydratedSession.id + ) + let resolvedSession = isSuppressed + ? await coworkService.stopChatSession( + hydratedSession.id, + scope + ) + : hydratedSession + + if ( + scope === FOLDER_SCOPE && + resolvedSession.folder_tree_pair?.source_root + ) { + resolvedSession = await coworkService.getChatSession( + resolvedSession.id, + scope + ) + } + + const sessionSummary = + latestSummary ?? buildChatSessionSummary(resolvedSession) + + setChatSessionsByScope((prev) => ({ + ...prev, + [scope]: upsertChatSessionSummary( + prev[scope], + sessionSummary + ) + })) + setActiveChatSessionIds((prev) => ({ + ...prev, + [scope]: resolvedSession.id + })) + setActiveChatSessionsByScope((prev) => ({ + ...prev, + [scope]: resolvedSession + })) + finalizeLiveRunState(resolvedSession.id) + suppressedStreamingSessionIdsRef.current.delete( + resolvedSession.id + ) + } catch (error) { + console.error('Failed to send Cowork chat message', error) + toast.error( + error instanceof Error + ? error.message + : 'Unable to send the Cowork message right now.' + ) + } finally { + if (currentSessionId) { + suppressedStreamingSessionIdsRef.current.delete( + currentSessionId + ) + } + setScopeSending(scope, false) + } + }, + [ + activeChatSessionIds, + activeChatSessionsByScope, + availableModels, + chatToolSettings, + finalizeLiveRunState, + currentChatScope, + prepareLiveRunState, + selectedGitHubRepository, + selectedModelId, + setScopeSending + ] + ) + + return ( + +
+ +
+ + +
+
+ setIsSidebarOpen(true)} + onGoHomepage={handleGoHomepage} + onOpenModeFirstPage={ + handleOpenModeFirstPage + } + isChatSessionsBoardOpen={ + isChatSessionsBoardOpen + } + onChatSessionsBoardOpenChange={ + handleChatSessionsBoardOpenChange + } + chatSessions={homepageChatSessions} + activeChatSessionId={ + homepageActiveChatSessionId + } + isChatSessionsLoading={ + isChatSessionsLoadingByScope[ + HOMEPAGE_SCOPE + ] + } + onNewChatSession={handleNewChatSession} + onSelectChatSession={ + handleSelectChatSession + } + onRenameChatSession={ + handleRenameChatSession + } + onDeleteChatSession={ + handleDeleteChatSession + } + isSessionsBoardOpen={isSessionsBoardOpen} + onSessionsBoardOpenChange={ + handleSessionsBoardOpenChange + } + modeSessions={folderChatSessions} + activeModeSessionId={ + folderActiveChatSessionId + } + isModeSessionsLoading={ + isChatSessionsLoadingByScope[ + FOLDER_SCOPE + ] + } + onSelectSession={handleSelectSession} + onRenameSession={handleRenameSession} + onDeleteSession={handleDeleteSession} + /> +
+ +
+
+ +
+
+
+ + + + + + + {pendingDeleteSession?.scope === HOMEPAGE_SCOPE + ? 'Delete chat session?' + : 'Delete folder session?'} + + + {`Session "${pendingDeleteSession?.title ?? ''}" will be removed from this device.`} + + + + + Cancel + + + + + +
+
+ ) +} + +export default CoworkPage diff --git a/frontend/src/components/cowork/cowork-sidebar.tsx b/frontend/src/components/cowork/cowork-sidebar.tsx new file mode 100644 index 000000000..13a24b8f2 --- /dev/null +++ b/frontend/src/components/cowork/cowork-sidebar.tsx @@ -0,0 +1,146 @@ +import { Icon } from '@/components/ui/icon' +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle +} from '@/components/ui/sheet' +import { cn } from '@/lib/utils' +import { + COWORK_HOME_LABEL, + COWORK_MODES, + type CoworkModeId +} from './cowork.constants' + +interface CoworkSidebarProps { + open: boolean + activeMode: CoworkModeId | null + onOpenChange: (open: boolean) => void + onGoHomepage: () => void + onSelectMode: (mode: CoworkModeId) => void +} + +const CoworkSidebar = ({ + open, + activeMode, + onOpenChange, + onGoHomepage, + onSelectMode +}: CoworkSidebarProps) => { + return ( + + + +
+
+ + II-Cowork + + + Workspace navigation + +
+ + + +
+
+
+ +
+

+ Modes +

+
+ {COWORK_MODES.map((mode) => { + const isActive = activeMode === mode.id + + return ( + + ) + })} +
+
+
+
+
+ ) +} + +export default CoworkSidebar diff --git a/frontend/src/components/cowork/cowork-tabs.tsx b/frontend/src/components/cowork/cowork-tabs.tsx new file mode 100644 index 000000000..1d4b276ed --- /dev/null +++ b/frontend/src/components/cowork/cowork-tabs.tsx @@ -0,0 +1,352 @@ +import ButtonIcon from '@/components/button-icon' +import { Pencil, Plus, Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Popover, + PopoverContent, + PopoverTrigger +} from '@/components/ui/popover' +import { + COWORK_HOME_LABEL, + getCoworkModeLabel, + type CoworkModeId +} from './cowork.constants' +import type { CoworkChatSessionSummary } from '@/typings/cowork' + +interface CoworkTabsProps { + activeMode: CoworkModeId | null + onOpenSidebar: () => void + onGoHomepage: () => void + onOpenModeFirstPage: () => void + isChatSessionsBoardOpen: boolean + onChatSessionsBoardOpenChange: (open: boolean) => void + chatSessions: CoworkChatSessionSummary[] + activeChatSessionId: string | null + isChatSessionsLoading: boolean + onNewChatSession: () => void + onSelectChatSession: (sessionId: string) => void + onRenameChatSession: (sessionId: string) => void + onDeleteChatSession: (sessionId: string) => void + isSessionsBoardOpen: boolean + onSessionsBoardOpenChange: (open: boolean) => void + modeSessions: CoworkChatSessionSummary[] + activeModeSessionId: string | null + isModeSessionsLoading: boolean + onSelectSession: (sessionId: string) => void + onRenameSession: (sessionId: string) => void + onDeleteSession: (sessionId: string) => void +} + +const getSessionPreviewLabel = (preview: string) => { + const trimmedPreview = preview.trim() + + if (!trimmedPreview.startsWith('Source:')) { + return trimmedPreview + } + + const normalizedPath = trimmedPreview + .slice('Source:'.length) + .trim() + .replace(/[\\/]+$/, '') + + if (!normalizedPath) { + return trimmedPreview + } + + const folderName = normalizedPath + .split(/[\\/]/) + .filter(Boolean) + .at(-1) + + return `Source: ${folderName || normalizedPath}` +} + +const CoworkTabs = ({ + activeMode, + onOpenSidebar, + onGoHomepage, + onOpenModeFirstPage, + isChatSessionsBoardOpen, + onChatSessionsBoardOpenChange, + chatSessions, + activeChatSessionId, + isChatSessionsLoading, + onNewChatSession, + onSelectChatSession, + onRenameChatSession, + onDeleteChatSession, + isSessionsBoardOpen, + onSessionsBoardOpenChange, + modeSessions, + activeModeSessionId, + isModeSessionsLoading, + onSelectSession, + onRenameSession, + onDeleteSession +}: CoworkTabsProps) => { + const currentLabel = activeMode ? getCoworkModeLabel(activeMode) : null + + return ( +
+
+
+ + + {currentLabel && ( + + )} +
+ {activeMode === null && ( + + + + + +
+

+ Chat Sessions +

+
+
+ + {isChatSessionsLoading ? ( +
+ Loading chat sessions... +
+ ) : chatSessions.length > 0 ? ( + chatSessions.map((session) => ( +
+ onSelectChatSession(session.id) + } + onKeyDown={(event) => { + if ( + event.key === 'Enter' || + event.key === ' ' + ) { + event.preventDefault() + onSelectChatSession( + session.id + ) + } + }} + > +
+

+ {session.title} +

+

+ {session.preview} +

+
+
+ + +
+
+ )) + ) : ( +
+ No chat sessions yet. +
+ )} +
+
+
+ )} + {activeMode === 'intelligent-folder' && ( +
+ + + + + +
+

+ Sessions +

+
+
+ {isModeSessionsLoading ? ( +
+ Loading mode sessions... +
+ ) : modeSessions.length > 0 ? ( + modeSessions.map((session) => ( +
+ onSelectSession(session.id) + } + onKeyDown={(event) => { + if ( + event.key === 'Enter' || + event.key === ' ' + ) { + event.preventDefault() + onSelectSession( + session.id + ) + } + }} + > +
+

+ {session.title} +

+

+ {getSessionPreviewLabel( + session.preview + )} +

+
+
+ + +
+
+ )) + ) : ( +
+ No mode sessions yet. +
+ )} +
+
+
+
+ )} +
+
+ ) +} + +export default CoworkTabs diff --git a/frontend/src/components/cowork/cowork.constants.ts b/frontend/src/components/cowork/cowork.constants.ts new file mode 100644 index 000000000..fa9f9bb1c --- /dev/null +++ b/frontend/src/components/cowork/cowork.constants.ts @@ -0,0 +1,13 @@ +export const COWORK_HOME_LABEL = 'Homepage' + +export const COWORK_MODES = [ + { + id: 'intelligent-folder', + label: 'Intelligent Folder' + } +] as const + +export type CoworkModeId = (typeof COWORK_MODES)[number]['id'] + +export const getCoworkModeLabel = (mode: CoworkModeId | null) => + COWORK_MODES.find((item) => item.id === mode)?.label ?? COWORK_HOME_LABEL diff --git a/frontend/src/components/cowork/intelligent-folder/cowork-folder-build.tsx b/frontend/src/components/cowork/intelligent-folder/cowork-folder-build.tsx new file mode 100644 index 000000000..e1ea0fa52 --- /dev/null +++ b/frontend/src/components/cowork/intelligent-folder/cowork-folder-build.tsx @@ -0,0 +1,81 @@ +import type { ActionStep } from '@/typings/agent' +import type { + CoworkChatSessionDetail, + CoworkLiveSessionState +} from '@/typings/cowork' +import { formatCoworkBuildHeaderLabel } from '../cowork-action-utils' +import CoworkBuildPanel from '../cowork-build/cowork-build-panel' +import { pickCoworkBuildRenderers } from '../cowork-build/cowork-build.renderers' +import { + CoworkBuildController, + CoworkBuildViewport, + useCoworkBuildState +} from '../cowork-build/cowork-build.shared' + +interface CoworkFolderBuildProps { + session?: CoworkChatSessionDetail | null + liveSession?: CoworkLiveSessionState | null + isRunning?: boolean + requestedAction?: ActionStep | null + requestedActionToken?: number +} + +const folderBuildRenderers = pickCoworkBuildRenderers( + 'desktopTool', + 'terminal', + 'code' +) + +const CoworkFolderBuild = ({ + session = null, + liveSession = null, + requestedAction = null, + requestedActionToken = 0 +}: CoworkFolderBuildProps) => { + const buildState = useCoworkBuildState({ + liveSession, + requestedAction, + requestedActionToken + }) + const isAwaitingNextAction = + buildState.isAwaitingTurnAction && buildState.hasActionHistory + const headerLabel = + formatCoworkBuildHeaderLabel(buildState.currentAction) || + buildState.currentAction?.data.tool_name || + (session?.run_status === 'completed' + ? 'Intelligent Folder run completed' + : isAwaitingNextAction + ? 'Generating' + : 'Cowork build') + + return ( + + } + controller={ + + } + footerText="Browse Intelligent Folder events one step at a time." + /> + ) +} + +export default CoworkFolderBuild diff --git a/frontend/src/components/cowork/intelligent-folder/cowork-folder-result.tsx b/frontend/src/components/cowork/intelligent-folder/cowork-folder-result.tsx new file mode 100644 index 000000000..3526e7e91 --- /dev/null +++ b/frontend/src/components/cowork/intelligent-folder/cowork-folder-result.tsx @@ -0,0 +1,40 @@ +import type { CoworkFolderTreeNode } from '@/typings/cowork' +import CoworkFolderTreeView from './cowork-folder-tree-view' + +interface CoworkFolderResultProps { + rootResult?: string + tree?: CoworkFolderTreeNode | null +} + +const CoworkFolderResult = ({ + rootResult = 'root_result', + tree = null +}: CoworkFolderResultProps) => { + if (!tree) { + return ( +
+
+

+ Result{' '} + + {rootResult} + +

+

+ No changes from the source folder yet. +

+
+
+ ) + } + + return ( + + ) +} + +export default CoworkFolderResult diff --git a/frontend/src/components/cowork/intelligent-folder/cowork-folder-source.tsx b/frontend/src/components/cowork/intelligent-folder/cowork-folder-source.tsx new file mode 100644 index 000000000..c74abb7d9 --- /dev/null +++ b/frontend/src/components/cowork/intelligent-folder/cowork-folder-source.tsx @@ -0,0 +1,40 @@ +import type { CoworkFolderTreeNode } from '@/typings/cowork' +import CoworkFolderTreeView from './cowork-folder-tree-view' + +interface CoworkFolderSourceProps { + rootSource?: string + tree?: CoworkFolderTreeNode | null +} + +const CoworkFolderSource = ({ + rootSource = 'root_source', + tree = null +}: CoworkFolderSourceProps) => { + if (!tree) { + return ( +
+
+

+ Source{' '} + + {rootSource} + +

+

+ Load a local path above to render its source tree. +

+
+
+ ) + } + + return ( + + ) +} + +export default CoworkFolderSource diff --git a/frontend/src/components/cowork/intelligent-folder/cowork-folder-steps.tsx b/frontend/src/components/cowork/intelligent-folder/cowork-folder-steps.tsx new file mode 100644 index 000000000..7b10b6f60 --- /dev/null +++ b/frontend/src/components/cowork/intelligent-folder/cowork-folder-steps.tsx @@ -0,0 +1,77 @@ +import clsx from 'clsx' +import { Icon } from '@/components/ui/icon' + +export type CoworkFolderStep = 'source' | 'build' | 'result' + +interface CoworkFolderStepsProps { + activeStep: CoworkFolderStep + onSelectStep: (step: CoworkFolderStep) => void +} + +const steps: { + id: CoworkFolderStep + label: string + icon: string +}[] = [ + { id: 'source', label: 'Source', icon: 'folder-open' }, + { id: 'build', label: 'Build', icon: 'wrench' }, + { id: 'result', label: 'Result', icon: 'ai-magic' } +] + +const CoworkFolderSteps = ({ + activeStep, + onSelectStep +}: CoworkFolderStepsProps) => { + return ( +
+ {steps.map((step, index) => { + const isActive = activeStep === step.id + + return ( +
+ + {index < steps.length - 1 && ( + + )} +
+ ) + })} +
+ ) +} + +export default CoworkFolderSteps diff --git a/frontend/src/components/cowork/intelligent-folder/cowork-folder-tree-icons.tsx b/frontend/src/components/cowork/intelligent-folder/cowork-folder-tree-icons.tsx new file mode 100644 index 000000000..a0b4d3827 --- /dev/null +++ b/frontend/src/components/cowork/intelligent-folder/cowork-folder-tree-icons.tsx @@ -0,0 +1,183 @@ +import type { LucideIcon } from 'lucide-react' +import { + Archive, + Database, + File, + FileCode2, + FileJson2, + FileText, + Folder, + FolderOpen, + Image, + Settings2, + Video +} from 'lucide-react' + +type TreeVisualGroup = + | 'folder' + | 'code' + | 'config' + | 'docs' + | 'data' + | 'media' + | 'archive' + | 'default' + +export interface TreeNodeVisual { + Icon: LucideIcon + label: string + chipClassName: string + containerClassName: string + iconClassName: string +} + +const VISUALS: Record = { + folder: { + Icon: Folder, + label: 'Folder', + chipClassName: + 'border-firefly/20 bg-firefly/10 text-firefly dark:border-sky-blue/20 dark:bg-sky-blue/10 dark:text-sky-blue', + containerClassName: + 'border-firefly/20 bg-firefly/10 dark:border-sky-blue/20 dark:bg-sky-blue/10', + iconClassName: 'text-firefly dark:text-sky-blue' + }, + code: { + Icon: FileCode2, + label: 'Code', + chipClassName: + 'border-violet-500/20 bg-violet-500/10 text-violet-600 dark:border-violet-400/20 dark:bg-violet-400/10 dark:text-violet-300', + containerClassName: + 'border-violet-500/20 bg-violet-500/10 dark:border-violet-400/20 dark:bg-violet-400/10', + iconClassName: 'text-violet-600 dark:text-violet-300' + }, + config: { + Icon: Settings2, + label: 'Config', + chipClassName: + 'border-amber-500/20 bg-amber-500/10 text-amber-700 dark:border-amber-300/20 dark:bg-amber-300/10 dark:text-amber-200', + containerClassName: + 'border-amber-500/20 bg-amber-500/10 dark:border-amber-300/20 dark:bg-amber-300/10', + iconClassName: 'text-amber-700 dark:text-amber-200' + }, + docs: { + Icon: FileText, + label: 'Docs', + chipClassName: + 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:border-emerald-300/20 dark:bg-emerald-300/10 dark:text-emerald-200', + containerClassName: + 'border-emerald-500/20 bg-emerald-500/10 dark:border-emerald-300/20 dark:bg-emerald-300/10', + iconClassName: 'text-emerald-700 dark:text-emerald-200' + }, + data: { + Icon: Database, + label: 'Data', + chipClassName: + 'border-cyan-500/20 bg-cyan-500/10 text-cyan-700 dark:border-cyan-300/20 dark:bg-cyan-300/10 dark:text-cyan-200', + containerClassName: + 'border-cyan-500/20 bg-cyan-500/10 dark:border-cyan-300/20 dark:bg-cyan-300/10', + iconClassName: 'text-cyan-700 dark:text-cyan-200' + }, + media: { + Icon: Image, + label: 'Media', + chipClassName: + 'border-pink-500/20 bg-pink-500/10 text-pink-700 dark:border-pink-300/20 dark:bg-pink-300/10 dark:text-pink-200', + containerClassName: + 'border-pink-500/20 bg-pink-500/10 dark:border-pink-300/20 dark:bg-pink-300/10', + iconClassName: 'text-pink-700 dark:text-pink-200' + }, + archive: { + Icon: Archive, + label: 'Archive', + chipClassName: + 'border-slate-500/20 bg-slate-500/10 text-slate-700 dark:border-slate-300/20 dark:bg-slate-300/10 dark:text-slate-200', + containerClassName: + 'border-slate-500/20 bg-slate-500/10 dark:border-slate-300/20 dark:bg-slate-300/10', + iconClassName: 'text-slate-700 dark:text-slate-200' + }, + default: { + Icon: File, + label: 'File', + chipClassName: + 'border-neutral-300 bg-neutral-100 text-neutral-700 dark:border-white/15 dark:bg-white/5 dark:text-white/70', + containerClassName: + 'border-neutral-300 bg-neutral-100 dark:border-white/15 dark:bg-white/5', + iconClassName: 'text-neutral-700 dark:text-white/70' + } +} + +const extensionGroups: Record = { + folder: [], + code: [ + 'ts', + 'tsx', + 'js', + 'jsx', + 'py', + 'css', + 'scss', + 'html', + 'sh', + 'ps1' + ], + config: ['env', 'yaml', 'yml', 'ini', 'toml'], + docs: ['md', 'mdx', 'txt'], + data: ['json', 'sql', 'csv'], + media: ['png', 'jpg', 'jpeg', 'svg', 'gif', 'mp4', 'webm'], + archive: ['zip', 'tar', 'gz'], + default: [] +} + +const getVisualGroup = (extension?: string): TreeVisualGroup => { + const normalized = extension?.replace('.', '').toLowerCase() + + if (!normalized) return 'default' + + if (normalized === 'json') return 'data' + if (normalized === 'mp4' || normalized === 'webm') return 'media' + + return ( + (Object.entries(extensionGroups).find(([, extensions]) => + extensions.includes(normalized) + )?.[0] as TreeVisualGroup | undefined) ?? 'default' + ) +} + +export const getTreeNodeVisual = ({ + kind, + extension, + expanded +}: { + kind: 'folder' | 'file' + extension?: string + expanded?: boolean +}): TreeNodeVisual => { + if (kind === 'folder') { + return { + ...VISUALS.folder, + Icon: expanded ? FolderOpen : Folder + } + } + + const group = getVisualGroup(extension) + const visual = VISUALS[group] + + if (group === 'media' && (extension === 'mp4' || extension === 'webm')) { + return { + ...visual, + Icon: Video, + label: 'Video' + } + } + + if (group === 'data' && extension === 'json') { + return { + ...visual, + Icon: FileJson2, + label: 'JSON' + } + } + + return visual +} + diff --git a/frontend/src/components/cowork/intelligent-folder/cowork-folder-tree-view.tsx b/frontend/src/components/cowork/intelligent-folder/cowork-folder-tree-view.tsx new file mode 100644 index 000000000..25f558e67 --- /dev/null +++ b/frontend/src/components/cowork/intelligent-folder/cowork-folder-tree-view.tsx @@ -0,0 +1,307 @@ +import { useEffect, useMemo, useState } from 'react' +import { ChevronDown, ChevronRight } from 'lucide-react' +import type { CoworkFolderTreeNode } from '@/typings/cowork' +import { cn } from '@/lib/utils' +import { getTreeNodeVisual } from './cowork-folder-tree-icons' + +export type FolderTreeNode = CoworkFolderTreeNode + +interface FlattenedTreeNode extends FolderTreeNode { + path: string +} + +interface CoworkFolderTreeViewProps { + label: string + rootPath: string + tree: FolderTreeNode +} + +const getAllFolderIds = (node: FolderTreeNode): string[] => [ + ...(node.kind === 'folder' ? [node.id] : []), + ...(node.children?.flatMap((child) => getAllFolderIds(child)) ?? []) +] + +const flattenTree = ( + node: FolderTreeNode, + rootPath: string, + parentPath = '' +): FlattenedTreeNode[] => { + const currentPath = parentPath ? `${parentPath}/${node.name}` : rootPath + + return [ + { ...node, path: currentPath }, + ...(node.children?.flatMap((child) => + flattenTree(child, rootPath, currentPath) + ) ?? []) + ] +} + +const getDirectChildCounts = (node: FolderTreeNode) => ({ + folders: + node.children?.filter((child) => child.kind === 'folder').length ?? 0, + files: node.children?.filter((child) => child.kind === 'file').length ?? 0 +}) + +const formatLastModified = (value?: string) => { + if (!value) { + return '-' + } + + const parsed = new Date(value) + if (Number.isNaN(parsed.getTime())) { + return value + } + + return parsed.toLocaleString() +} + +const renderTreeNode = ({ + node, + depth, + expandedIds, + selectedId, + onToggle, + onSelect +}: { + node: FolderTreeNode + depth: number + expandedIds: Set + selectedId: string + onToggle: (id: string) => void + onSelect: (id: string) => void +}) => { + const hasChildren = Boolean(node.children?.length) + const isExpanded = expandedIds.has(node.id) + const isSelected = selectedId === node.id + const visual = getTreeNodeVisual({ + kind: node.kind, + extension: node.extension, + expanded: isExpanded + }) + + return ( +
+ + {hasChildren && + isExpanded && + node.children?.map((child) => + renderTreeNode({ + node: child, + depth: depth + 1, + expandedIds, + selectedId, + onToggle, + onSelect + }) + )} +
+ ) +} + +const CoworkFolderTreeView = ({ + label, + rootPath, + tree +}: CoworkFolderTreeViewProps) => { + const [expandedIds, setExpandedIds] = useState>( + () => new Set(getAllFolderIds(tree)) + ) + const [selectedId, setSelectedId] = useState(() => tree.id) + + useEffect(() => { + setExpandedIds(new Set(getAllFolderIds(tree))) + setSelectedId(tree.id) + }, [tree]) + + const flatTree = useMemo(() => flattenTree(tree, rootPath), [rootPath, tree]) + const selectedNode = useMemo( + () => flatTree.find((node) => node.id === selectedId) ?? flatTree[0], + [flatTree, selectedId] + ) + const selectedChildCounts = useMemo( + () => getDirectChildCounts(selectedNode), + [selectedNode] + ) + const selectedDetails = useMemo( + () => + selectedNode.kind === 'folder' + ? [ + { + label: 'Node type', + value: 'Folder' + }, + { + label: 'Subfolders', + value: selectedChildCounts.folders + }, + { + label: 'Files', + value: selectedChildCounts.files + } + ] + : [ + { + label: 'Node type', + value: selectedNode.extension?.toUpperCase() ?? 'File' + }, + { + label: 'Size', + value: selectedNode.size ?? '-' + }, + { + label: 'Last modified', + value: formatLastModified(selectedNode.last_modified) + } + ], + [selectedChildCounts.files, selectedChildCounts.folders, selectedNode] + ) + + return ( +
+
+
+

+ {label}{' '} + + {rootPath} + +

+
+
+
+
+
+ {renderTreeNode({ + node: tree, + depth: 0, + expandedIds, + selectedId, + onToggle: (id) => + setExpandedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }), + onSelect: setSelectedId + })} +
+
+ +
+
+

+ Selected +

+
+ + {(() => { + const visual = getTreeNodeVisual({ + kind: selectedNode.kind, + extension: selectedNode.extension, + expanded: expandedIds.has(selectedNode.id) + }) + return ( + + ) + })()} + +
+

+ {selectedNode.name} +

+

+ {selectedNode.path} +

+
+
+
+ {selectedDetails.map((item) => ( +
+ + {item.label} + + + {item.value} + +
+ ))} +
+
+
+
+
+ ) +} + +export default CoworkFolderTreeView diff --git a/frontend/src/components/cowork/intelligent-folder/cowork-folder-undo-redo.tsx b/frontend/src/components/cowork/intelligent-folder/cowork-folder-undo-redo.tsx new file mode 100644 index 000000000..e2e5987cf --- /dev/null +++ b/frontend/src/components/cowork/intelligent-folder/cowork-folder-undo-redo.tsx @@ -0,0 +1,101 @@ +import clsx from 'clsx' +import { Icon } from '@/components/ui/icon' +import type { CoworkFolderUndoState } from '@/typings/cowork' + +interface CoworkFolderUndoRedoProps { + state?: CoworkFolderUndoState | null + onUndo: () => void + onRedo: () => void + isBusy?: boolean + className?: string +} + +interface PillButtonProps { + label: string + iconName: string + enabled: boolean + onClick: () => void + ariaLabel: string +} + +/** + * Single Undo or Redo pill — shares the visual language of + * {@link CoworkFolderSteps} (size-7 circle + icon). Disabled pills are + * kept visible (not removed) so the layout doesn't jitter every time + * the user walks to a timeline boundary. + */ +const PillButton = ({ label, iconName, enabled, onClick, ariaLabel }: PillButtonProps) => { + return ( + + ) +} + +/** + * Full Undo / Redo / Counter row rendered to the right of the + * Source / Build / Result stepper in Intelligent Folder mode. + * + * - Rendered only when `state.total > 0` (i.e. the session has + * committed at least one snapshot to the timeline). + * - Counter shows "current/total" — current is 1-based. + * - Both pills are always shown; disabled when navigation in that + * direction isn't possible, so the row doesn't shift around as the + * cursor walks. + */ +const CoworkFolderUndoRedo = ({ + state, + onUndo, + onRedo, + isBusy = false, + className +}: CoworkFolderUndoRedoProps) => { + if (!state || state.total === 0) { + return null + } + + const canUndo = state.can_undo && !isBusy + const canRedo = state.can_redo && !isBusy + + return ( +
+ + + {state.current}/{state.total} + + +
+ ) +} + +export default CoworkFolderUndoRedo diff --git a/frontend/src/components/cowork/intelligent-folder/folder-tree-utils.ts b/frontend/src/components/cowork/intelligent-folder/folder-tree-utils.ts new file mode 100644 index 000000000..a6f2d8d98 --- /dev/null +++ b/frontend/src/components/cowork/intelligent-folder/folder-tree-utils.ts @@ -0,0 +1,5 @@ +export const FOLDER_TREE_READ_OPTIONS = { + max_depth: 6, + max_entries: 5000, + include_hidden: false +} as const diff --git a/frontend/src/components/cowork/modes/intelligent-folder.tsx b/frontend/src/components/cowork/modes/intelligent-folder.tsx new file mode 100644 index 000000000..5d389f495 --- /dev/null +++ b/frontend/src/components/cowork/modes/intelligent-folder.tsx @@ -0,0 +1,613 @@ +import { AnimatePresence, motion } from 'framer-motion' +import { FolderOpen, LoaderCircle } from 'lucide-react' +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useState +} from 'react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { coworkService } from '@/services/cowork.service' +import type { + CoworkChatSessionDetail, + CoworkLiveSessionState, + CoworkFolderTreeNode, + CoworkFolderTreePair +} from '@/typings/cowork' +import type { ActionStep } from '@/typings/agent' +import { cn } from '@/lib/utils' +import { getCurrentWindow } from '@tauri-apps/api/window' +import { isCoworkBuildPanelActionVisible } from '../cowork-action-utils' +import CoworkFolderBuild from '../intelligent-folder/cowork-folder-build' +import CoworkFolderResult from '../intelligent-folder/cowork-folder-result' +import CoworkFolderSource from '../intelligent-folder/cowork-folder-source' +import { FOLDER_TREE_READ_OPTIONS } from '../intelligent-folder/folder-tree-utils' +import CoworkFolderSteps, { + type CoworkFolderStep +} from '../intelligent-folder/cowork-folder-steps' +import CoworkFolderUndoRedo from '../intelligent-folder/cowork-folder-undo-redo' + +const folderStepTransition = { + duration: 0.14, + ease: 'easeOut' +} as const + +interface IntelligentFolderModeProps { + resetVersion?: number + session?: CoworkChatSessionDetail | null + liveSession?: CoworkLiveSessionState | null + isSessionLoading?: boolean + isSending?: boolean + onWorkflowActiveChange?: (active: boolean) => void + onSessionCreated?: (session: CoworkChatSessionDetail) => void + requestedBuildAction?: ActionStep | null + requestedBuildActionToken?: number +} + +const IntelligentFolderMode = ({ + resetVersion = 0, + session = null, + liveSession = null, + isSessionLoading = false, + isSending = false, + onWorkflowActiveChange, + onSessionCreated, + requestedBuildAction = null, + requestedBuildActionToken = 0 +}: IntelligentFolderModeProps) => { + const [sourcePath, setSourcePath] = useState('') + const [isLoadingPath, setIsLoadingPath] = useState(false) + const [loadError, setLoadError] = useState(null) + const [isDragActive, setIsDragActive] = useState(false) + const [loadedTreePair, setLoadedTreePair] = + useState(null) + const [isProcessing, setIsProcessing] = useState(false) + const [activeStep, setActiveStep] = useState('source') + // True while an Undo or Redo RPC is in flight — disables the button so + // the user can't double-click and trigger two swaps at once. + const [isUndoRedoBusy, setIsUndoRedoBusy] = useState(false) + + useEffect(() => { + onWorkflowActiveChange?.(isProcessing) + }, [isProcessing, onWorkflowActiveChange]) + + useLayoutEffect(() => { + setSourcePath('') + setLoadError(null) + setIsDragActive(false) + setLoadedTreePair(null) + setIsLoadingPath(false) + setIsProcessing(false) + setActiveStep('source') + }, [resetVersion]) + + useLayoutEffect(() => { + if (!session) return + setSourcePath(session.folder_tree_pair?.source_root ?? '') + setLoadedTreePair(null) + setActiveStep('source') + setIsProcessing(true) + }, [session?.id, session?.folder_tree_pair?.source_root]) + + const folderTreePair = loadedTreePair ?? session?.folder_tree_pair + const isFreshLoadedFolderAwaitingSessionSync = Boolean( + loadedTreePair && + loadedTreePair.source_root !== + session?.folder_tree_pair?.source_root + ) + const modeResultTree: CoworkFolderTreeNode | null = + folderTreePair?.result_tree ?? null + const hasVisibleBuildAction = Boolean( + isCoworkBuildPanelActionVisible(liveSession?.current_action) || + liveSession?.event_messages?.some((message) => + isCoworkBuildPanelActionVisible(message.action) + ) + ) + const hasStreamingBuildActivity = Boolean( + liveSession?.thinking.trim() || + liveSession?.response.trim() || + hasVisibleBuildAction || + liveSession?.is_awaiting_turn_action + ) + const isRunCompleted = session?.run_status === 'completed' + const sessionContentKey = useMemo( + () => loadedTreePair?.source_root ?? session?.id ?? 'folder-entry', + [loadedTreePair?.source_root, session?.id] + ) + + useEffect(() => { + if (!isProcessing) { + return + } + + if (isFreshLoadedFolderAwaitingSessionSync) { + setActiveStep('source') + return + } + + if (requestedBuildAction) { + setActiveStep('build') + return + } + + if (isRunCompleted) { + setActiveStep('result') + return + } + + if ( + isSending || + hasStreamingBuildActivity || + session?.run_status === 'thinking' || + session?.run_status === 'waiting_for_input' + ) { + setActiveStep('build') + } + }, [ + hasStreamingBuildActivity, + isFreshLoadedFolderAwaitingSessionSync, + isProcessing, + isRunCompleted, + isSending, + requestedBuildAction, + requestedBuildActionToken, + session?.run_status + ]) + + const resolveFolderPath = async (candidatePaths: string[]) => { + let lastError: Error | null = null + + for (const rawPath of candidatePaths) { + const nextPath = rawPath.trim() + if (!nextPath) { + continue + } + + try { + const probeTree = await coworkService.readPathTree(nextPath, { + max_depth: 0, + max_entries: 1, + include_hidden: false + }) + + if (probeTree.kind === 'folder') { + return nextPath + } + } catch (error) { + lastError = + error instanceof Error + ? error + : new Error('Failed to inspect the dropped path.') + } + } + + throw ( + lastError ?? + new Error( + 'Please choose or drop a folder. Files are not supported.' + ) + ) + } + + const getErrorMessage = (error: unknown, fallback: string) => { + if (error instanceof Error && error.message.trim()) { + return error.message + } + + if (typeof error === 'string' && error.trim()) { + return error + } + + if ( + typeof error === 'object' && + error !== null && + 'message' in error && + typeof error.message === 'string' && + error.message.trim() + ) { + return error.message + } + + return fallback + } + + const handleLoadPath = async (pathValue?: string | string[]) => { + if (isLoadingPath) { + return + } + + setIsLoadingPath(true) + setLoadError(null) + + try { + const resolvedPath = await resolveFolderPath( + Array.isArray(pathValue) ? pathValue : [pathValue ?? sourcePath] + ) + + setSourcePath(resolvedPath) + + const sourceTree = await coworkService.readPathTree(resolvedPath, { + ...FOLDER_TREE_READ_OPTIONS + }) + + const treePair = { + source_root: resolvedPath, + result_root: resolvedPath, + source_tree: sourceTree, + result_tree: null + } + const persistedSession = + await coworkService.createFolderSession(treePair) + + setLoadedTreePair({ + source_root: + persistedSession.folder_tree_pair?.source_root ?? + resolvedPath, + result_root: + persistedSession.folder_tree_pair?.result_root ?? + resolvedPath, + source_tree: + persistedSession.folder_tree_pair?.source_tree ?? + sourceTree, + result_tree: + persistedSession.folder_tree_pair?.result_tree ?? null + }) + onSessionCreated?.(persistedSession) + setActiveStep('source') + setIsProcessing(true) + } catch (error) { + setLoadError( + getErrorMessage( + error, + 'Failed to load the path from your machine.' + ) + ) + } finally { + setIsLoadingPath(false) + } + } + + useEffect(() => { + if (isProcessing) { + return + } + + let isMounted = true + + const registerListener = async () => { + const currentWindow = getCurrentWindow() + const unlisten = await currentWindow.onDragDropEvent((event) => { + if (!isMounted) { + return + } + + if ( + event.payload.type === 'enter' || + event.payload.type === 'over' + ) { + setIsDragActive(true) + return + } + + if (event.payload.type === 'leave') { + setIsDragActive(false) + return + } + + if (event.payload.type === 'drop') { + setIsDragActive(false) + if (event.payload.paths.length > 0) { + void handleLoadPath(event.payload.paths) + } + } + }) + + if (!isMounted) { + unlisten() + } + + return unlisten + } + + let cleanup: (() => void) | undefined + + void registerListener().then((unlisten) => { + cleanup = unlisten + }) + + return () => { + isMounted = false + cleanup?.() + } + }, [isProcessing]) + + const handlePickFolder = async () => { + if (isLoadingPath) { + return + } + + setLoadError(null) + + try { + const pickedPath = await coworkService.pickFolderPath(sourcePath) + if (!pickedPath) { + return + } + + await handleLoadPath(pickedPath) + } catch (error) { + setLoadError( + getErrorMessage(error, 'Failed to open the folder picker.') + ) + } + } + + // Session-update callback is stable across Undo/Redo — reuse the same + // channel the rest of the flow uses so the parent page's session cache + // picks up the flipped `undo_slot` and the refreshed `result_tree`. + const handleUndoRedo = useCallback( + async (action: 'undo' | 'redo') => { + if (!session?.id || isUndoRedoBusy) return + setIsUndoRedoBusy(true) + try { + const updated = + action === 'undo' + ? await coworkService.undoFolder(session.id) + : await coworkService.redoFolder(session.id) + onSessionCreated?.(updated) + } catch (error) { + toast.error( + getErrorMessage( + error, + action === 'undo' + ? 'Failed to undo folder changes.' + : 'Failed to redo folder changes.' + ) + ) + } finally { + setIsUndoRedoBusy(false) + } + }, + [session?.id, isUndoRedoBusy, onSessionCreated] + ) + + return ( + + {!isProcessing ? ( + +
+
+

+ Intelligent Folder +

+

+ Start a new Intelligent Folder session +

+ {/*

+ Enter a local path, browse with the native picker, or + drop a folder anywhere on this screen. +

*/} +
+ +
+ + setSourcePath(event.target.value) + } + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + void handleLoadPath() + } + }} + placeholder="/home/user/folder" + className="h-10 flex-1 rounded-full border-neutral-300 bg-white px-4 dark:border-white/15 dark:bg-black" + /> + +
+
+
+ + + +
+

+ Drag and drop folders only +

+

+ Or open the native folder picker to + choose a folder from your machine. +

+
+ +
+
+ {loadError && ( +

+ {loadError} +

+ )} +
+
+ + ) : ( + +
+
+ {/* Left spacer — balances the Undo/Redo slot on the right + so the steps row stays visually centered. */} +
+ +
+ handleUndoRedo('undo')} + onRedo={() => handleUndoRedo('redo')} + isBusy={ + isUndoRedoBusy || + isSending || + isSessionLoading + } + /> +
+
+
+ + + {activeStep === 'source' && ( + + )} + {activeStep === 'build' && ( + + )} + {activeStep === 'result' && ( + + )} + + +
+
+
+ Switching session... +
+
+
+
+
+ + )} + + ) +} + +export default IntelligentFolderMode diff --git a/frontend/src/components/home-mobile.tsx b/frontend/src/components/home-mobile.tsx index 3e07cd24b..78bd9b2b7 100644 --- a/frontend/src/components/home-mobile.tsx +++ b/frontend/src/components/home-mobile.tsx @@ -5,6 +5,7 @@ import { useState, type KeyboardEvent as ReactKeyboardEvent } from 'react' +import { useNavigate } from 'react-router' import { Icon } from '@/components/ui/icon' import { @@ -36,6 +37,11 @@ import { MiniTool } from '@/constants/media-tools' import { MediaTemplateExplorer } from './media/media-template-explorer' import SwitchLanguage from './switch-language' import LearnMore from './learn-more' +import { + COWORK_ROUTE, + isAgenticQuestionMode +} from '@/utils/question-mode' +import { isTauri } from '@/utils/is-tauri' interface HomeMobileProps { currentQuestion: string @@ -177,6 +183,7 @@ const HomeMobile = ({ }: HomeMobileProps) => { const { t } = useTranslation() const dispatch = useAppDispatch() + const navigate = useNavigate() const questionMode = useAppSelector(selectQuestionMode) const selectedModel = useAppSelector(selectSelectedModel) const availableModels = useAppSelector(selectAvailableModels) @@ -210,6 +217,11 @@ const HomeMobile = ({ () => questionMode === QUESTION_MODE.CHAT, [questionMode] ) + const isAgenticMode = useMemo( + () => isAgenticQuestionMode(questionMode), + [questionMode] + ) + const featureMode = isChatMode ? QUESTION_MODE.CHAT : QUESTION_MODE.AGENT const selectedSuggestionType = useMemo(() => { if ( @@ -220,14 +232,14 @@ const HomeMobile = ({ } if ( - questionMode === QUESTION_MODE.AGENT && + isAgenticMode && selectedFeature !== AGENT_TYPE.GENERAL ) { return selectedFeature } return null - }, [chatMediaPreference.enabled, questionMode, selectedFeature]) + }, [chatMediaPreference.enabled, isAgenticMode, questionMode, selectedFeature]) const suggestionsToRender = useMemo(() => { if (!selectedSuggestionType) return [] @@ -265,6 +277,16 @@ const HomeMobile = ({ dispatch(setQuestionMode(QUESTION_MODE.CHAT)) } + const handleSwitchToAgentMode = () => { + clearMediaPreference() + dispatch(setQuestionMode(QUESTION_MODE.AGENT)) + } + + const handleSwitchToCowork = () => { + clearMediaPreference() + navigate(COWORK_ROUTE) + } + const handleMediaTemplateSelect = (template: MediaTemplate | undefined) => { setTimeout(() => { scrollRef.current?.scrollIntoView({ behavior: 'smooth' }) @@ -381,11 +403,9 @@ const HomeMobile = ({ + {isTauri && ( + + )}
@@ -426,7 +467,7 @@ const HomeMobile = ({
handleSelectFeature(tile)} diff --git a/frontend/src/components/layouts/root-layout.tsx b/frontend/src/components/layouts/root-layout.tsx index 3054efb18..249fe4cf7 100644 --- a/frontend/src/components/layouts/root-layout.tsx +++ b/frontend/src/components/layouts/root-layout.tsx @@ -7,12 +7,14 @@ import { import { useNavigationLeaveSession } from '@/hooks/use-navigation-leave-session' import { useWebSocketAuthSync } from '@/hooks/use-websocket-auth-sync' import { ChatProvider } from '@/hooks/use-chat-query' +import { useCoworkAuthSync } from '@/hooks/use-cowork-auth-sync' function RootLayoutContent() { useNavigationLeaveSession() // Establish WebSocket connection immediately when auth token is available useWebSocketAuthSync() - + useCoworkAuthSync() + return } diff --git a/frontend/src/components/question-input.tsx b/frontend/src/components/question-input.tsx index 7aaaae6b3..b1bc5389d 100644 --- a/frontend/src/components/question-input.tsx +++ b/frontend/src/components/question-input.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { useLocation, useParams } from 'react-router' +import { useLocation, useNavigate, useParams } from 'react-router' import { type MiniTool } from '@/constants/media-tools' import { getMediaTypeConfig } from '@/constants/media-type-config' @@ -74,6 +74,10 @@ import clsx from 'clsx' import { useMediaModels } from '@/hooks/use-media-models' import { useChat } from '@/hooks/use-chat-query' import { StorybookStylePicker } from './media/image/image-settings-picker' +import { + COWORK_ROUTE, + isAgenticQuestionMode +} from '@/utils/question-mode' interface QuestionInputProps { value: string @@ -84,6 +88,7 @@ interface QuestionInputProps { textareaClassName?: string placeholder?: string isDisabled?: boolean + submitDisabled?: boolean handleEnhancePrompt?: (payload: { prompt: string onSuccess: (res: string) => void @@ -128,6 +133,7 @@ const QuestionInput = ({ handleKeyDown, handleSubmit, isDisabled, + submitDisabled = false, handleEnhancePrompt, handleCancel, onFilesChange, @@ -159,6 +165,7 @@ const QuestionInput = ({ }: QuestionInputProps) => { const { t } = useTranslation() const dispatch = useAppDispatch() + const navigate = useNavigate() const requireClearFiles = useAppSelector(selectRequireClearFiles) const uploadedFiles = useAppSelector(selectUploadedFiles) const currentMessageFileIds = useAppSelector(selectCurrentMessageFileIds) @@ -214,6 +221,7 @@ const QuestionInput = ({ const isChatRoute = normalizedPathname === '/chat' || normalizedPathname.endsWith('/chat') const isSessionView = Boolean(sessionId) || isChatRoute + const isAgenticMode = isAgenticQuestionMode(questionMode) const textareaRef = useRef(null) const clearedAttachmentIdsRef = useRef>(new Set()) @@ -353,6 +361,7 @@ const QuestionInput = ({ if ( !submissionValue || isDisabled || + submitDisabled || isCreatingSession || files?.some((file) => file.loading) || isUploading @@ -516,6 +525,12 @@ const QuestionInput = ({ } const handleSelectMode = (mode: QUESTION_MODE) => { + if (mode === QUESTION_MODE.COWORK) { + clearMediaPreference() + navigate(COWORK_ROUTE) + return + } + dispatch(setQuestionMode(mode)) setTimeout(() => { textareaRef.current?.focus() @@ -523,7 +538,7 @@ const QuestionInput = ({ if (mode === QUESTION_MODE.CHAT) { dispatch(setSelectedFeature(AGENT_TYPE.GENERAL)) } - if (mode === QUESTION_MODE.AGENT) { + if (isAgenticQuestionMode(mode)) { clearMediaPreference() } } @@ -1250,7 +1265,7 @@ const QuestionInput = ({
)} {!hideBuildModeSelector && - questionMode === QUESTION_MODE.AGENT && + isAgenticMode && (selectedFeature === AGENT_TYPE.GENERAL || selectedFeature === AGENT_TYPE.WEBSITE_BUILD || @@ -1339,7 +1354,7 @@ const QuestionInput = ({ } /> - {questionMode === QUESTION_MODE.AGENT && ( + {isAgenticMode && ( { @@ -1379,6 +1394,7 @@ const QuestionInput = ({ (!currentTextareaValue.trim() && !hasMiniToolSelection) || isDisabled || + submitDisabled || isCreatingSession || files?.some((file) => file.loading) || isUploading || @@ -1452,7 +1468,7 @@ const QuestionInput = ({
{!hideSuggestions && - questionMode === QUESTION_MODE.AGENT && + isAgenticMode && selectedFeature !== AGENT_TYPE.GENERAL && (
diff --git a/frontend/src/components/question-mode-selector.tsx b/frontend/src/components/question-mode-selector.tsx index 7e0c33622..85cd5c2c0 100644 --- a/frontend/src/components/question-mode-selector.tsx +++ b/frontend/src/components/question-mode-selector.tsx @@ -3,6 +3,7 @@ import { QUESTION_MODE } from '@/typings' import { cn } from '@/lib/utils' import { useTranslation } from 'react-i18next' import { useIsSageTheme } from '@/hooks/use-is-sage-theme' +import { isTauri } from '@/utils/is-tauri' interface ModeSelectorProps { selectedMode: QUESTION_MODE @@ -13,65 +14,60 @@ interface ModeSelectorProps { const ModeSelector = ({ selectedMode, hide, onSelect }: ModeSelectorProps) => { const { t } = useTranslation() const isSage = useIsSageTheme() + const modes = [ + { + type: QUESTION_MODE.CHAT, + icon: 'chat-fill', + label: t('question.mode.chat') + }, + { + type: QUESTION_MODE.AGENT, + icon: 'agent-fill', + label: t('question.mode.agent') + }, + { + type: QUESTION_MODE.COWORK, + icon: 'messages', + label: 'II-Cowork' + } + ].filter((mode) => isTauri || mode.type !== QUESTION_MODE.COWORK) if (hide) return null return (
- - + {modes.map((mode) => { + const isActive = selectedMode === mode.type + + return ( + + ) + })}
) } diff --git a/frontend/src/components/ui/popover.tsx b/frontend/src/components/ui/popover.tsx index 006345edf..6a1759529 100644 --- a/frontend/src/components/ui/popover.tsx +++ b/frontend/src/components/ui/popover.tsx @@ -19,8 +19,11 @@ function PopoverContent({ className, align = "center", sideOffset = 4, + instant = false, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + instant?: boolean +}) { return ( state.user) // Derive isAuthenticated from the presence of a valid access token - const isAuthenticated = !!localStorage.getItem(ACCESS_TOKEN) + const isAuthenticated = !!getStoredAccessToken() const fetchAvailableModels = useCallback(async () => { try { @@ -62,7 +66,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { useEffect(() => { const initializeAuth = async () => { try { - const accessToken = localStorage.getItem(ACCESS_TOKEN) + const accessToken = getStoredAccessToken() if (accessToken) { try { @@ -108,9 +112,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { }) // Store the access token immediately to trigger WebSocket connection - localStorage.setItem(ACCESS_TOKEN, res.access_token) - // Dispatch a custom event to notify WebSocket to connect immediately - window.dispatchEvent(new CustomEvent('auth-token-set')) + storeAccessToken(res.access_token) // Get user information using the access token (in parallel with WebSocket connection) const userRes = await authService.getCurrentUser() @@ -126,7 +128,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { } const logout = () => { - localStorage.removeItem(ACCESS_TOKEN) + clearAccessToken() dispatch(clearUser()) dispatch(clearFavorites()) dispatch(clearPins()) diff --git a/frontend/src/hooks/use-cowork-auth-sync.tsx b/frontend/src/hooks/use-cowork-auth-sync.tsx new file mode 100644 index 000000000..5e74deea0 --- /dev/null +++ b/frontend/src/hooks/use-cowork-auth-sync.tsx @@ -0,0 +1,69 @@ +import { useEffect, useRef } from 'react' +import { invoke } from '@tauri-apps/api/core' +import { + AUTH_TOKEN_CLEARED_EVENT, + AUTH_TOKEN_SET_EVENT, + getStoredAccessToken +} from '@/utils/auth-token' + +const isTauri = + typeof window !== 'undefined' && + !!(window as unknown as { __TAURI_INTERNALS__?: unknown }) + .__TAURI_INTERNALS__ + +type SyncPayload = { + accessToken: string | null + apiBaseUrl: string +} + +export function useCoworkAuthSync() { + const lastPayloadRef = useRef(null) + + useEffect(() => { + if (!isTauri) return undefined + + const apiBaseUrl = + import.meta.env.VITE_API_URL || 'http://localhost:8000' + + const syncAuthContext = async () => { + const payload: SyncPayload = { + accessToken: getStoredAccessToken(), + apiBaseUrl + } + + if ( + lastPayloadRef.current?.accessToken === payload.accessToken && + lastPayloadRef.current?.apiBaseUrl === payload.apiBaseUrl + ) { + return + } + + await invoke('sync_cowork_auth_context', payload) + lastPayloadRef.current = payload + } + + const syncSilently = () => { + void syncAuthContext().catch((error) => { + console.error('Failed to sync cowork auth context:', error) + }) + } + + syncSilently() + + const handleStorageChange = (event: StorageEvent) => { + if (event.key) { + syncSilently() + } + } + + window.addEventListener('storage', handleStorageChange) + window.addEventListener(AUTH_TOKEN_SET_EVENT, syncSilently) + window.addEventListener(AUTH_TOKEN_CLEARED_EVENT, syncSilently) + + return () => { + window.removeEventListener('storage', handleStorageChange) + window.removeEventListener(AUTH_TOKEN_SET_EVENT, syncSilently) + window.removeEventListener(AUTH_TOKEN_CLEARED_EVENT, syncSilently) + } + }, []) +} diff --git a/frontend/src/hooks/use-websocket-auth-sync.tsx b/frontend/src/hooks/use-websocket-auth-sync.tsx index 3daef8e12..79e856a49 100644 --- a/frontend/src/hooks/use-websocket-auth-sync.tsx +++ b/frontend/src/hooks/use-websocket-auth-sync.tsx @@ -1,6 +1,11 @@ import { useEffect } from 'react' -import { ACCESS_TOKEN } from '@/constants/auth' import { useWebSocketContext } from '@/contexts/websocket-context' +import { ACCESS_TOKEN } from '@/constants/auth' +import { + AUTH_TOKEN_CLEARED_EVENT, + AUTH_TOKEN_SET_EVENT, + getStoredAccessToken +} from '@/utils/auth-token' /** * Hook that monitors auth token changes and reconnects the WebSocket @@ -17,25 +22,50 @@ export function useWebSocketAuthSync() { // Detect token changes from other browser tabs const handleStorageChange = (e: StorageEvent) => { if (e.key === ACCESS_TOKEN && e.newValue && !socket?.connected) { - console.log('WebSocket: Token changed via storage event, reconnecting...') + console.log( + 'WebSocket: Token changed via storage event, reconnecting...' + ) connectSocket() + return + } + + if (e.key === ACCESS_TOKEN && !e.newValue && socket?.connected) { + console.log( + 'WebSocket: Token cleared via storage event, disconnecting...' + ) + socket.disconnect() } } // Detect token set in the same tab (e.g. after login or token refresh) const handleAuthTokenSet = () => { - const token = localStorage.getItem(ACCESS_TOKEN) + const token = getStoredAccessToken() if (token && !socket?.connected) { console.log('WebSocket: Auth token set event, reconnecting...') connectSocket() } } + const handleAuthTokenCleared = () => { + if (socket?.connected) { + console.log('WebSocket: Auth token cleared, disconnecting...') + socket.disconnect() + } + } + window.addEventListener('storage', handleStorageChange) - window.addEventListener('auth-token-set', handleAuthTokenSet) + window.addEventListener(AUTH_TOKEN_SET_EVENT, handleAuthTokenSet) + window.addEventListener( + AUTH_TOKEN_CLEARED_EVENT, + handleAuthTokenCleared + ) return () => { window.removeEventListener('storage', handleStorageChange) - window.removeEventListener('auth-token-set', handleAuthTokenSet) + window.removeEventListener(AUTH_TOKEN_SET_EVENT, handleAuthTokenSet) + window.removeEventListener( + AUTH_TOKEN_CLEARED_EVENT, + handleAuthTokenCleared + ) } }, [connectSocket, socket?.connected]) -} \ No newline at end of file +} diff --git a/frontend/src/lib/axios.ts b/frontend/src/lib/axios.ts index 70e6286e8..5892450e9 100644 --- a/frontend/src/lib/axios.ts +++ b/frontend/src/lib/axios.ts @@ -1,5 +1,5 @@ -import { ACCESS_TOKEN } from '@/constants/auth' import axios from 'axios' +import { clearAccessToken, getStoredAccessToken } from '@/utils/auth-token' const axiosInstance = axios.create({ baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000', @@ -10,7 +10,7 @@ const axiosInstance = axios.create({ axiosInstance.interceptors.request.use( (config) => { - const token = localStorage.getItem(ACCESS_TOKEN) + const token = getStoredAccessToken() if (token) { config.headers.Authorization = `Bearer ${token}` } @@ -30,10 +30,11 @@ axiosInstance.interceptors.response.use( // Only logout if it's NOT a connector-specific endpoint // Connector endpoints return 401 when the connector token is invalid, // not when the user session is invalid - const isConnectorEndpoint = error.config?.url?.includes('/v1/connectors/') + const isConnectorEndpoint = + error.config?.url?.includes('/connectors/') if (!isConnectorEndpoint) { - localStorage.removeItem(ACCESS_TOKEN) + clearAccessToken() // Don't redirect to login if we're on a share route if (!window.location.pathname.startsWith('/share/')) { window.location.href = '/login' diff --git a/frontend/src/services/auth.service.ts b/frontend/src/services/auth.service.ts index 86711808f..66885c15e 100644 --- a/frontend/src/services/auth.service.ts +++ b/frontend/src/services/auth.service.ts @@ -17,6 +17,30 @@ class AuthService { return response.data } + /** Fetch the Google OAuth URL for desktop login (no browser redirect to backend). */ + async getDesktopGoogleLoginUrl( + desktopState: string + ): Promise { + const response = await axiosInstance.get<{ url: string }>( + '/auth/oauth/google/desktop/login-url', + { params: { desktop_state: desktopState } } + ) + return response.data.url + } + + /** Poll for a desktop auth token stored by the backend after Google login. */ + async pollDesktopToken( + state: string + ): Promise { + const response = await axiosInstance.get< + { status: 'pending' } | GoogleAuthResponse + >('/auth/oauth/google/poll', { params: { state } }) + if ('status' in response.data && response.data.status === 'pending') { + return null + } + return response.data as GoogleAuthResponse + } + async logout(): Promise { await axiosInstance.post('/api/auth/logout') } diff --git a/frontend/src/services/cowork.service.ts b/frontend/src/services/cowork.service.ts new file mode 100644 index 000000000..aa3bca44e --- /dev/null +++ b/frontend/src/services/cowork.service.ts @@ -0,0 +1,189 @@ +import { invoke } from '@tauri-apps/api/core' +import { open } from '@tauri-apps/plugin-dialog' +import type { + CoworkChatScope, + CoworkChatSendMessageRequest, + CoworkChatSendMessageResponse, + CoworkChatSessionDetail, + CoworkChatSessionSummary, + CoworkFolderTreeNode, + CoworkFolderTreePair +} from '@/typings/cowork' + +const HOMEPAGE_SCOPE: CoworkChatScope = 'homepage' +const FOLDER_SCOPE: CoworkChatScope = 'intelligent-folder' + +interface ReadPathTreeOptions { + max_depth?: number + max_entries?: number + include_hidden?: boolean +} + +class CoworkService { + async pickFolderPath(defaultPath?: string): Promise { + const selectedPath = await open({ + directory: true, + multiple: false, + recursive: true, + title: 'Choose a source folder', + defaultPath: defaultPath?.trim() || undefined + }) + + if (Array.isArray(selectedPath)) { + return selectedPath[0] ?? null + } + + return selectedPath ?? null + } + + async readPathTree( + path: string, + options?: ReadPathTreeOptions + ): Promise { + return invoke('read_path_tree', { + path, + options + }) + } + + async createFolderSession( + treePair: CoworkFolderTreePair + ): Promise { + return invoke('create_folder_session', { + treePair + }) + } + + async createHomepageChatSession( + title: string + ): Promise { + return invoke('create_homepage_chat_session', { + title + }) + } + + async updateFolderSession( + session: CoworkChatSessionDetail + ): Promise { + return invoke('update_folder_session', { + session + }) + } + + async updateHomepageChatSession( + session: CoworkChatSessionDetail + ): Promise { + return invoke('update_homepage_chat_session', { + session + }) + } + + async renameHomepageChatSession( + sessionId: string, + title: string + ): Promise { + return invoke('rename_homepage_chat_session', { + sessionId, + title + }) + } + + async deleteHomepageChatSession(sessionId: string): Promise { + return invoke('delete_homepage_chat_session', { + sessionId + }) + } + + async renameFolderSession( + sessionId: string, + title: string + ): Promise { + return invoke('rename_folder_session', { + sessionId, + title + }) + } + + async deleteFolderSession(sessionId: string): Promise { + return invoke('delete_folder_session', { + sessionId + }) + } + + async getChatSessions( + scope: CoworkChatScope + ): Promise { + if (scope === HOMEPAGE_SCOPE) { + return invoke( + 'list_homepage_chat_sessions' + ) + } + + if (scope === FOLDER_SCOPE) { + return invoke('list_folder_sessions') + } + + return [] + } + + async getChatSession( + sessionId: string, + scope: CoworkChatScope = HOMEPAGE_SCOPE + ): Promise { + if (scope === HOMEPAGE_SCOPE) { + return invoke( + 'get_homepage_chat_session', + { + sessionId + } + ) + } + + if (scope === FOLDER_SCOPE) { + return invoke('get_folder_session', { + sessionId + }) + } + + throw new Error(`Unsupported cowork scope: ${scope}`) + } + + async sendChatMessage( + payload: CoworkChatSendMessageRequest + ): Promise { + return invoke( + 'send_cowork_chat_message', + { + request: { + ...payload, + session_id: payload.session_id?.trim() || null, + runtime_kind: payload.runtime_kind ?? 'remote' + } + } + ) + } + + async stopChatSession( + sessionId: string, + scope: CoworkChatScope + ): Promise { + return invoke('stop_cowork_chat_session', { + scope, + sessionId + }) + } + + async undoFolder(sessionId: string): Promise { + return invoke('undo_cowork_folder', { + sessionId + }) + } + + async redoFolder(sessionId: string): Promise { + return invoke('redo_cowork_folder', { + sessionId + }) + } +} + +export const coworkService = new CoworkService() diff --git a/frontend/src/state/api/session.api.ts b/frontend/src/state/api/session.api.ts index 1cd4776a0..a794da001 100644 --- a/frontend/src/state/api/session.api.ts +++ b/frontend/src/state/api/session.api.ts @@ -5,8 +5,12 @@ import type { FetchBaseQueryError } from '@reduxjs/toolkit/query' import type { ISession } from '@/typings/agent' -import type { UpdateSessionRequest, ForkSessionRequest, ForkSessionResponse } from '@/typings/session' -import { ACCESS_TOKEN } from '@/constants/auth' +import type { + UpdateSessionRequest, + ForkSessionRequest, + ForkSessionResponse +} from '@/typings/session' +import { getStoredAccessToken, clearAccessToken } from '@/utils/auth-token' import { normalizeSession, normalizeSessions } from '@/services/session-normalizer' const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' @@ -14,7 +18,7 @@ const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' const baseQuery = fetchBaseQuery({ baseUrl: `${API_URL}/v1`, prepareHeaders: (headers) => { - const token = localStorage.getItem(ACCESS_TOKEN) + const token = getStoredAccessToken() if (token) { headers.set('Authorization', `Bearer ${token}`) } @@ -30,7 +34,7 @@ const baseQueryWithReauth: BaseQueryFn< > = async (args, api, extraOptions) => { const result = await baseQuery(args, api, extraOptions) if (result.error && result.error.status === 401) { - localStorage.removeItem(ACCESS_TOKEN) + clearAccessToken() // Don't redirect to login if we're on a share route if (!window.location.pathname.startsWith('/share/')) { window.location.href = '/login' diff --git a/frontend/src/state/api/user.api.ts b/frontend/src/state/api/user.api.ts index a40b64e7d..5f252819b 100644 --- a/frontend/src/state/api/user.api.ts +++ b/frontend/src/state/api/user.api.ts @@ -11,14 +11,14 @@ import type { ReservationHistoryResponse, SessionUsageDetailResponse } from '@/typings/user' -import { ACCESS_TOKEN } from '@/constants/auth' +import { getStoredAccessToken, clearAccessToken } from '@/utils/auth-token' const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' const baseQuery = fetchBaseQuery({ baseUrl: `${API_URL}/v1`, prepareHeaders: (headers) => { - const token = localStorage.getItem(ACCESS_TOKEN) + const token = getStoredAccessToken() if (token) { headers.set('Authorization', `Bearer ${token}`) } @@ -34,7 +34,7 @@ const baseQueryWithReauth: BaseQueryFn< > = async (args, api, extraOptions) => { const result = await baseQuery(args, api, extraOptions) if (result.error && result.error.status === 401) { - localStorage.removeItem(ACCESS_TOKEN) + clearAccessToken() // Don't redirect to login if we're on a share route if (!window.location.pathname.startsWith('/share/')) { window.location.href = '/login' diff --git a/frontend/src/typings/agent.ts b/frontend/src/typings/agent.ts index cbfaa8b2d..0e3fc2c86 100644 --- a/frontend/src/typings/agent.ts +++ b/frontend/src/typings/agent.ts @@ -24,7 +24,8 @@ export enum VIEW_MODE { export enum QUESTION_MODE { AGENT = 'agent', - CHAT = 'chat' + CHAT = 'chat', + COWORK = 'cowork' } export enum BUILD_MODE { @@ -132,7 +133,7 @@ export enum ErrorCode { UNKNOWN_FORK_TYPE = 'unknown_fork_type', // Design mode DESIGN_SYNC_STATE_ERROR = 'design_sync_state_error', - SLIDE_DECK_SYNC_STATE_ERROR = 'slide_deck_sync_state_error', + SLIDE_DECK_SYNC_STATE_ERROR = 'slide_deck_sync_state_error' } export enum AgentEvent { @@ -335,6 +336,8 @@ export enum TOOL { ASK_USER_ENV = 'ask_user_env', ASK_USER_SELECT = 'ask_user_select', SKILL = 'Skill', + DESKTOP_SKILL_RUN = 'desktop_skill_run', + WASM_RUN = 'wasm_run', MOBILE_APP_INIT = 'mobile_app_init', RESTART_MOBILE_SERVER = 'restart_mobile_server' } @@ -472,6 +475,16 @@ export type ActionStep = { secrets?: Array<{ key: string; value: string }> attachments?: string[] skill?: string + skill_name?: string + module?: string + entrypoint?: string + keep_workspace?: boolean + timeout_seconds?: number + input_json?: Record + input_files?: Array<{ + path: string + name?: string + }> } result?: | string @@ -570,6 +583,7 @@ export interface ISession { metadata?: { media?: ChatMediaPreference fork_info?: ForkInfo + question_mode?: QUESTION_MODE [key: string]: unknown } } diff --git a/frontend/src/typings/cowork.ts b/frontend/src/typings/cowork.ts new file mode 100644 index 000000000..59f674093 --- /dev/null +++ b/frontend/src/typings/cowork.ts @@ -0,0 +1,204 @@ +import type { ActionStep, Message } from './agent' + +export type CoworkChatMessageRole = 'user' | 'assistant' +export type CoworkChatScope = 'homepage' | 'intelligent-folder' + +export interface CoworkGitHubRepositoryContext { + owner: string + name: string + full_name: string + default_branch: string +} + +export interface CoworkChatToolSettings { + web_search: boolean + web_visit: boolean + image_search: boolean + code_interpreter?: boolean + generate_image?: boolean + generate_video?: boolean +} + +export interface CoworkFolderTreeNode { + id: string + name: string + kind: 'folder' | 'file' + extension?: string + size?: string + last_modified?: string + children?: CoworkFolderTreeNode[] +} + +export interface CoworkFolderTreePair { + source_root: string + result_root: string + source_tree: CoworkFolderTreeNode + result_tree: CoworkFolderTreeNode | null +} + +/** + * Mirrors Rust's `FolderUndoState` struct. Lightweight UI hint derived + * from the session's snapshot timeline on disk: + * + * - `can_undo` / `can_redo` — whether the corresponding pill should + * be enabled. + * - `current` — 1-based index of the snapshot currently materialised + * on disk. `0` when the timeline is empty (no runs have committed + * a change yet). + * - `total` — number of snapshots in the timeline. `0` when empty. + * + * When `total === 0`, the UI hides the Undo/Redo row entirely. + */ +export interface CoworkFolderUndoState { + can_undo: boolean + can_redo: boolean + current: number + total: number +} + +export interface CoworkChatMessage { + id: string + role: CoworkChatMessageRole + content: string + created_at: string + is_think_message?: boolean +} + +export interface CoworkChatSessionSummary { + id: string + scope: CoworkChatScope + title: string + preview: string + updated_at: string + message_count: number +} + +export type CoworkChatRunStatus = + | 'idle' + | 'thinking' + | 'waiting_for_input' + | 'completed' + | 'stopped' + +export type CoworkAgentRuntimeKind = 'remote' | 'local' + +export interface CoworkChatFile { + id: string + file_name: string + file_size: number + content_type: string + created_at: string +} + +export interface CoworkChatSessionDetail extends CoworkChatSessionSummary { + runtime_kind?: CoworkAgentRuntimeKind + runtime_session_id?: string + messages: CoworkChatMessage[] + runtime_events: CoworkRuntimeEventPayload[] + files: CoworkChatFile[] + run_status: CoworkChatRunStatus + folder_tree_pair?: CoworkFolderTreePair + undo_state?: CoworkFolderUndoState +} + +export type CoworkChatEvent = + | { + type: 'session.created' | 'session.updated' + session: CoworkChatSessionSummary + } + | { + type: 'message.created' + scope: CoworkChatScope + session_id: string + message: CoworkChatMessage + } + | { + type: 'files.updated' + scope: CoworkChatScope + session_id: string + files: CoworkChatFile[] + } + | { + type: 'status.updated' + scope: CoworkChatScope + session_id: string + status: CoworkChatRunStatus + } + +export interface CoworkRuntimeEventPayload { + type: 'runtime.event' + scope: CoworkChatScope + session_id: string + runtime_event_type: string + runtime_event_id?: string + runtime_created_at?: string + run_status?: string + emitted_at: string + content: Record +} + +export type CoworkChatLiveEvent = CoworkChatEvent | CoworkRuntimeEventPayload + +export type CoworkLiveActivityStatus = + | 'running' + | 'completed' + | 'waiting' + | 'error' + +export interface CoworkLiveToolCall { + id: string + name: string + display_name: string + input?: string + result?: string + status: CoworkLiveActivityStatus + logo?: string + skill_name?: string + agent_name?: string +} + +export interface CoworkLiveActivity { + id: string + runtime_event_type: string + title: string + detail?: string + timestamp: string + status?: CoworkLiveActivityStatus + tool_call_id?: string + tool_name?: string + skill_name?: string + agent_name?: string +} + +export interface CoworkLiveSessionState { + session_id: string + scope: CoworkChatScope + thinking: string + response: string + is_awaiting_turn_action?: boolean + thinking_message_id?: string + response_message_id?: string + thinking_started_at?: string + response_started_at?: string + tool_calls: CoworkLiveToolCall[] + activities: CoworkLiveActivity[] + event_messages: Message[] + current_action?: ActionStep + last_event_at?: string + latest_runtime_event_type?: string +} + +export interface CoworkChatSendMessageRequest { + session_id?: string | null + runtime_kind?: CoworkAgentRuntimeKind + scope: CoworkChatScope + content: string + model_id: string + tools?: CoworkChatToolSettings + github_repository?: CoworkGitHubRepositoryContext +} + +export interface CoworkChatSendMessageResponse { + session_id: string + events: CoworkChatEvent[] +} diff --git a/frontend/src/utils/auth-token.ts b/frontend/src/utils/auth-token.ts new file mode 100644 index 000000000..264aca446 --- /dev/null +++ b/frontend/src/utils/auth-token.ts @@ -0,0 +1,24 @@ +import { ACCESS_TOKEN } from '@/constants/auth' + +export const AUTH_TOKEN_SET_EVENT = 'auth-token-set' +export const AUTH_TOKEN_CLEARED_EVENT = 'auth-token-cleared' + +function dispatchAuthEvent(eventName: string) { + if (typeof window === 'undefined') return + window.dispatchEvent(new CustomEvent(eventName)) +} + +export function getStoredAccessToken(): string | null { + if (typeof window === 'undefined') return null + return localStorage.getItem(ACCESS_TOKEN) +} + +export function storeAccessToken(token: string): void { + localStorage.setItem(ACCESS_TOKEN, token) + dispatchAuthEvent(AUTH_TOKEN_SET_EVENT) +} + +export function clearAccessToken(): void { + localStorage.removeItem(ACCESS_TOKEN) + dispatchAuthEvent(AUTH_TOKEN_CLEARED_EVENT) +} diff --git a/frontend/src/utils/is-tauri.ts b/frontend/src/utils/is-tauri.ts new file mode 100644 index 000000000..ecb5967e9 --- /dev/null +++ b/frontend/src/utils/is-tauri.ts @@ -0,0 +1,4 @@ +export const isTauri = + typeof window !== 'undefined' && + !!(window as unknown as { __TAURI_INTERNALS__?: unknown }) + .__TAURI_INTERNALS__ diff --git a/frontend/src/utils/question-mode.ts b/frontend/src/utils/question-mode.ts new file mode 100644 index 000000000..8b3085778 --- /dev/null +++ b/frontend/src/utils/question-mode.ts @@ -0,0 +1,46 @@ +import { QUESTION_MODE } from '@/typings/agent' + +type QuestionModeValue = QUESTION_MODE | string | null | undefined + +export const COWORK_ROUTE = '/cowork' + +const normalizeQuestionMode = ( + mode: QuestionModeValue +): QUESTION_MODE | null => { + switch (mode) { + case QUESTION_MODE.CHAT: + return QUESTION_MODE.CHAT + case QUESTION_MODE.COWORK: + return QUESTION_MODE.COWORK + case QUESTION_MODE.AGENT: + return QUESTION_MODE.AGENT + default: + return null + } +} + +export const isAgenticQuestionMode = ( + mode: QuestionModeValue +): mode is QUESTION_MODE.AGENT => + normalizeQuestionMode(mode) === QUESTION_MODE.AGENT + +export const isCoworkQuestionMode = ( + mode: QuestionModeValue +): mode is QUESTION_MODE.COWORK => + normalizeQuestionMode(mode) === QUESTION_MODE.COWORK + +interface SessionRouteOptions { + sessionId: string + agentType?: string | null +} + +export const getSessionRoute = ({ + sessionId, + agentType +}: SessionRouteOptions) => { + if (agentType === 'chat') { + return `/chat?id=${sessionId}` + } + + return `/${sessionId}` +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 8bf2ae67a..85f7ebd6c 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -6,6 +6,7 @@ interface ImportMetaEnv { readonly VITE_API_URL: string readonly VITE_GOOGLE_CLIENT_ID?: string readonly VITE_STRIPE_PUBLISHABLE_KEY?: string + readonly VITE_FRONTEND_URL?: string } interface ImportMeta { diff --git a/src/ii_agent/agents/types.py b/src/ii_agent/agents/types.py index 5b876addd..9778778ea 100644 --- a/src/ii_agent/agents/types.py +++ b/src/ii_agent/agents/types.py @@ -30,6 +30,7 @@ class AgentType(StrEnum): FAST_RESEARCH = "fast_research" RESEARCH_TO_WEBSITE = "research_to_website" MOBILE_APP = "mobile_app" + COWORK = "cowork" __all__ = ["AgentType"] diff --git a/src/ii_agent/auth/router.py b/src/ii_agent/auth/router.py index 7669a55ee..bf158eab9 100644 --- a/src/ii_agent/auth/router.py +++ b/src/ii_agent/auth/router.py @@ -10,6 +10,7 @@ import httpx from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse, RedirectResponse +from pydantic import BaseModel from fastapi_sso.sso.google import GoogleSSO from itsdangerous import URLSafeSerializer, BadSignature @@ -356,6 +357,28 @@ async def google_login(settings: SettingsDep): params={"prompt": "consent", "access_type": "offline"} ) +@router.get("/oauth/google/desktop/login-url") +async def google_desktop_login_url(desktop_state: str): + """Return the Google OAuth URL as JSON (no redirect). + + The desktop frontend fetches this, then redirects the browser itself + so the user never sees a backend URL in the address bar. + """ + settings = get_settings() + google_sso = GoogleSSO( + settings.oauth.google_client_id or "", + settings.oauth.google_client_secret or "", + redirect_uri=settings.oauth.google_redirect_uri, + ) + state_serializer = URLSafeSerializer(settings.oauth.session_secret_key, salt="desktop-google") + custom_state = state_serializer.dumps({"desktop_state": desktop_state}) + + async with google_sso: + url = await google_sso.get_login_url( + params={"prompt": "consent", "access_type": "offline"}, + state=custom_state, + ) + return {"url": url} @router.get("/oauth/google/callback") async def google_callback( @@ -444,12 +467,64 @@ async def google_callback( str(user_stored.role), ) + # Check if this was a desktop app login (desktop_state encoded in OAuth state). + desktop_state_value: Optional[str] = None + raw_state = request.query_params.get("state") + if raw_state: + try: + ds_serializer = URLSafeSerializer(settings.oauth.session_secret_key, salt="desktop-google") + decoded = ds_serializer.loads(raw_state) + if isinstance(decoded, dict): + desktop_state_value = decoded.get("desktop_state") + except BadSignature: + pass + + if desktop_state_value: + from ii_agent.core.redis.client import get_redis_client + redis_client = get_redis_client() + + token_payload = { + "access_token": token_payload["access_token"], + "refresh_token": token_payload["refresh_token"], + "token_type": "bearer", + "expires_in": token_payload["expires_in"], + } + await redis_client.setex( + f"desktop_auth:{desktop_state_value}", 300, json.dumps(token_payload) + ) + # Redirect to frontend — show "login successful" message. + # The desktop app is polling and will pick up the token automatically. + frontend_origin = settings.ii_frontend_url if settings.ii_frontend_url else "http://localhost:1420" + return RedirectResponse( + url=f"{frontend_origin}/login?desktop_auth=success", + status_code=302, + ) + return TokenResponse( access_token=token_payload["access_token"], refresh_token=token_payload["refresh_token"], expires_in=token_payload["expires_in"], ) +@router.get("/oauth/google/poll") +async def google_poll(state: str): + """Poll for desktop auth token. + + The desktop app calls this endpoint with the ``state`` it generated + before opening the system browser. Returns the token payload once + the user completes login, or 202 while still waiting. + """ + from ii_agent.core.redis.client import get_redis_client + redis_client = get_redis_client() + + data = await redis_client.get(f"desktop_auth:{state}") + if not data: + return {"status": "pending"} + + # Delete after first successful read so the token can't be replayed. + await redis_client.delete(f"desktop_auth:{state}") + return json.loads(data) + @router.get("/me", response_model=UserPublic) async def reader_user_me( @@ -469,3 +544,26 @@ async def reader_user_me( subscription_current_period_end=current_user.subscription_current_period_end, language=str(current_user.language or "en"), ) + +class UpdatePreferencesRequest(BaseModel): + has_memory: Optional[bool] = None + + +@router.patch("/me/preferences") +async def update_user_preferences( + current_user: CurrentUser, + db: DBSession, + body: UpdatePreferencesRequest, +) -> dict[str, Any]: + """Update user preference settings (memory, personalization).""" + import copy + + metadata = copy.deepcopy(current_user.user_metadata) if isinstance(current_user.user_metadata, dict) else {} + old_prefs = metadata.get("preferences", {}) + updates = body.model_dump(exclude_none=True) + metadata["preferences"] = {**old_prefs, **updates} + + current_user.user_metadata = metadata + await db.commit() + + return {"message": "Preferences updated successfully", "preferences": metadata.get("preferences", {})} diff --git a/src/ii_agent/clients/__init__.py b/src/ii_agent/clients/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/ii_agent/clients/cowork/__init__.py b/src/ii_agent/clients/cowork/__init__.py new file mode 100644 index 000000000..e35c60e9a --- /dev/null +++ b/src/ii_agent/clients/cowork/__init__.py @@ -0,0 +1,3 @@ +from ii_agent.clients.cowork.factory import cowork_agent_factory + +__all__ = ["cowork_agent_factory"] \ No newline at end of file diff --git a/src/ii_agent/clients/cowork/config.py b/src/ii_agent/clients/cowork/config.py new file mode 100644 index 000000000..5a15a0f80 --- /dev/null +++ b/src/ii_agent/clients/cowork/config.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +#: Fallback for ``requested_capabilities.core_tools`` (names from +#: :data:`TOOL_CLASS_MAP`) when the request doesn't specify any. +COWORK_DEFAULT_CORE_TOOLS: set[str] = set() + +#: Fallback for ``requested_capabilities.core_skills`` (names from the +#: user's persisted ``SkillTool`` registry) when the request doesn't +#: specify any. +COWORK_DEFAULT_CORE_SKILLS: set[str] = set() + +#: Fallback for ``requested_capabilities.connector`` (e.g. ``"github"``, +#: ``"google_drive"``) when the request doesn't specify one. The +#: connector still requires a wired ``connector_tool`` to instantiate. +COWORK_DEFAULT_CONNECTORS: set[str] = set() + +#: Fallback system prompt — used only when the request doesn't ship its own. +DEFAULT_SYSTEM_PROMPT = ( + "You are the Cowork agent inside II Agent desktop. " + "Help the user reason over local cowork context, use tools deliberately, and keep responses concise and actionable." +) + +#: Heading for the client-defined skill catalog appended to the system prompt. +CLIENT_SKILL_HEADING = "Skills available in the cowork mode:" + +#: Tag used by the shared capability helpers when emitting warnings. +LOG_PREFIX = "cowork" \ No newline at end of file diff --git a/src/ii_agent/clients/cowork/factory.py b/src/ii_agent/clients/cowork/factory.py new file mode 100644 index 000000000..637335c74 --- /dev/null +++ b/src/ii_agent/clients/cowork/factory.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from ii_agent.agents.agent import IIAgent +from ii_agent.agents.connector.base import BaseConnectorTool +from ii_agent.agents.factory.agent import AgentFactory, agent_factory as default_agent_factory +from ii_agent.agents.factory.tool_manager import AgentToolManager +from ii_agent.agents.models.utils import get_model +from ii_agent.agents.sessions.base import SessionStore +from ii_agent.agents.skills.base import SkillCreator +from ii_agent.agents.types import AgentType +from ii_agent.clients.cowork.config import ( + COWORK_DEFAULT_CONNECTORS, + COWORK_DEFAULT_CORE_SKILLS, + COWORK_DEFAULT_CORE_TOOLS, + CLIENT_SKILL_HEADING, + DEFAULT_SYSTEM_PROMPT, + LOG_PREFIX, +) +from ii_agent.clients.proxy_capabilities import ( + RequestedCapabilities, + build_client_skill_prompt, + build_client_tools, + dedupe_tools, + include_connector_tools, + include_core_skills, + include_core_tools, +) +from ii_agent.core.config.llm_config import LLMConfig +from ii_agent.core.logger import logger +from ii_server.core.workspace import WorkspaceManager + + +class CoworkAgentFactory: + """Build a runtime-configured ``IIAgent`` for the cowork mode.""" + + def __init__(self, factory: AgentFactory): + self._factory = factory + + async def create_agent( + self, + user_id: str, + session_id: str, + llm_config: LLMConfig, + agent_type: AgentType = AgentType.COWORK, + workspace_manager: Optional[WorkspaceManager] = None, + session_store: Optional[SessionStore] = None, + tool_args: Optional[Dict[str, Any]] = None, + metadata: Optional[Dict[str, Any]] = None, + system_prompt: Optional[str] = None, + skill_creator: Optional[SkillCreator] = None, + connector_tool: Optional[BaseConnectorTool] = None, + requested_capabilities: Optional[Any] = None, + ) -> IIAgent: + logger.info( + "Creating cowork %s agent for session %s", + agent_type, + session_id, + ) + + capabilities = RequestedCapabilities.parse(requested_capabilities) + agent_tools: List[Any] = [] + + # Client-defined + client_tools = build_client_tools( + capabilities.client_tools, log_prefix=LOG_PREFIX + ) + if client_tools: + agent_tools.extend(client_tools) + logger.info( + "[cowork] Added %d client-defined tools", + len(client_tools), + ) + client_skill_prompt = build_client_skill_prompt( + capabilities.client_skills, heading=CLIENT_SKILL_HEADING + ) + + # Core tools — user's request wins; default kicks in if request is empty. + core_tools = include_core_tools( + capabilities.core_tools, + default_core_tools=COWORK_DEFAULT_CORE_TOOLS, + log_prefix=LOG_PREFIX, + ) + if core_tools: + agent_tools.extend(core_tools) + logger.info( + "[cowork] Added %d core tools", len(core_tools) + ) + + skill_tool, core_skill_prompt = await include_core_skills( + capabilities.core_skills, + skill_creator=skill_creator, + default_core_skills=COWORK_DEFAULT_CORE_SKILLS, + log_prefix=LOG_PREFIX, + ) + if skill_tool is not None: + agent_tools.append(skill_tool) + logger.info( + "[cowork] Added SkillTool with %d skills", + len(skill_tool._skills_registry), + ) + + # Connector — user's choice unless missing, then first default. + connector_tools = await include_connector_tools( + capabilities.connector, + connector_tool=connector_tool, + workspace_manager=workspace_manager, + default_connectors=COWORK_DEFAULT_CONNECTORS, + log_prefix=LOG_PREFIX, + ) + if connector_tools: + agent_tools.extend(connector_tools) + + # Final assembly + agent_tools = dedupe_tools(agent_tools) + AgentToolManager.log_tool_summary( + agent_tools, f"cowork agent {agent_type.value}" + ) + + model = get_model(llm_config.provider, llm_config=llm_config) + + if not system_prompt: + system_prompt = DEFAULT_SYSTEM_PROMPT + for prompt_section in (client_skill_prompt, core_skill_prompt): + if prompt_section: + system_prompt = f"{system_prompt}\n\n{prompt_section}" + + agent = IIAgent( + user_id=user_id, + session_id=session_id, + model=model, + name=f"{agent_type.value}_agent", + tools=agent_tools, + system_message=system_prompt, + session_store=session_store, + metadata=metadata, + sub_agents=[], + retries=0, + stream=True, + stream_events=True, + store_events=True, + ) + agent.set_id() + + logger.info( + "[cowork] Created %s agent with %d tools", + agent_type.value, + len(agent_tools), + ) + return agent + + +cowork_agent_factory = CoworkAgentFactory(default_agent_factory) diff --git a/src/ii_agent/clients/proxy_capabilities.py b/src/ii_agent/clients/proxy_capabilities.py new file mode 100644 index 000000000..1fac80632 --- /dev/null +++ b/src/ii_agent/clients/proxy_capabilities.py @@ -0,0 +1,443 @@ +"""Proxy capabilities — what each client exposes to the agent loop. + +A "capability" is anything the LLM can invoke during an agent turn: +either a tool (callable with structured input) or a skill (advisory recipe +appended to the system prompt). Each request from a ``ii_agent.clients.*`` +subpackage carries a single ``requested_capabilities`` payload that may +mix four sources: + +* ``client_tools`` — JSON descriptors shipped over the wire by the client. + Become :class:`Function` stubs flagged + ``external_execution=True``: the agent loop pauses on + call and waits for the client to ship results back via + ``external_tool_results``. +* ``client_skills`` — JSON descriptors rendered into the system prompt as a + short advisory catalog. No Python execution. +* ``core_tools`` — names from ii-agent's :data:`TOOL_CLASS_MAP` that the + client wants to opt in to. Each client subpackage + decides which core tools it allows via an + ``allowed_core_tools`` whitelist. +* ``core_skills`` — names from the user's persisted ``SkillTool`` registry + to keep, gated by an ``allowed_core_skills`` whitelist. + +By default each client subpackage loads nothing from the core catalog — +it only exposes what the request explicitly asks for AND what the client +has whitelisted. + +Helpers are pure (no I/O, no DB) and parameterised by ``log_prefix`` / +``heading`` so multi-client deployments stay greppable. +""" + +from __future__ import annotations + +from typing import Any, Iterable, List, Optional + +from ii_agent.agents.factory.tool_manager import AgentToolManager +from ii_agent.agents.factory.tools import TOOL_CLASS_MAP +from ii_agent.agents.skills.prompt_db import generate_skill_tool_description +from ii_agent.agents.tools.function import Function +from ii_agent.core.logger import logger + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +def _normalize_name_set(values: Optional[Iterable[str]]) -> Optional[set[str]]: + if not values: + return None + normalized = { + value.strip().lower() + for value in values + if isinstance(value, str) and value.strip() + } + return normalized or None + + +def _normalize_name_list(values: Optional[Iterable[str]]) -> List[str]: + if not values: + return [] + out: List[str] = [] + seen: set[str] = set() + for value in values: + if not isinstance(value, str): + continue + cleaned = value.strip().lower() + if not cleaned or cleaned in seen: + continue + seen.add(cleaned) + out.append(cleaned) + return out + + +def _tool_name(tool: Any) -> str: + name = getattr(tool, "name", "") + return name.strip().lower() if isinstance(name, str) else "" + + +def _coerce_to_dict(value: Optional[Any]) -> Optional[dict[str, Any]]: + """Accept either a Pydantic model (``model_dump``) or a plain ``dict``.""" + if value is None: + return None + if hasattr(value, "model_dump"): + return value.model_dump() + if isinstance(value, dict): + return value + return None + + +def _pick_requested( + requested: Optional[Iterable[str]], + default: Optional[Iterable[str]], +) -> set[str]: + """Union of the client default and the user's request. + + Both inputs are normalized (lowercased, stripped, de-duped). For + example, ``default = {A, B}`` + ``requested = {A, C}`` → ``{A, B, C}``. + Returns an empty set when neither side specifies anything. + """ + return (_normalize_name_set(requested) or set()) | ( + _normalize_name_set(default) or set() + ) + + +# --------------------------------------------------------------------------- +# Requested capabilities (parsed view of the wire payload) +# --------------------------------------------------------------------------- + + +class RequestedCapabilities: + """Normalized view of a client's ``requested_capabilities`` payload. + + Attributes are always lists (possibly empty), so callers don't have to + juggle ``None`` checks at the use site. + """ + + __slots__ = ( + "client_tools", + "client_skills", + "core_tools", + "core_skills", + "connector", + "raw", + ) + + def __init__( + self, + *, + client_tools: List[dict[str, Any]], + client_skills: List[dict[str, Any]], + core_tools: List[str], + core_skills: List[str], + connector: Optional[str], + raw: dict[str, Any], + ) -> None: + self.client_tools = client_tools + self.client_skills = client_skills + self.core_tools = core_tools + self.core_skills = core_skills + self.connector = connector + self.raw = raw + + @classmethod + def parse(cls, payload: Optional[Any]) -> "RequestedCapabilities": + as_dict = _coerce_to_dict(payload) or {} + + def _list_of_dicts(key: str) -> List[dict[str, Any]]: + value = as_dict.get(key) or [] + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, dict)] + + connector = as_dict.get("connector") + if not isinstance(connector, str) or not connector.strip(): + connector = None + else: + connector = connector.strip().lower() + + return cls( + client_tools=_list_of_dicts("client_tools"), + client_skills=_list_of_dicts("client_skills"), + core_tools=_normalize_name_list(as_dict.get("core_tools")), + core_skills=_normalize_name_list(as_dict.get("core_skills")), + connector=connector, + raw=as_dict, + ) + + +# --------------------------------------------------------------------------- +# Core agent capabilities (per-client defaults unioned with the user request) +# --------------------------------------------------------------------------- + + +def include_core_tools( + requested: Optional[Iterable[str]], + *, + default_core_tools: Optional[Iterable[str]] = None, + log_prefix: str = "client", +) -> List[Any]: + """Instantiate core tools from the union of the user request and + ``default_core_tools``. + + No allow-list gating: both the request and the defaults are trusted. + Names not registered in :data:`TOOL_CLASS_MAP` are dropped with a + warning. + """ + selected = _pick_requested(requested, default_core_tools) + if not selected: + return [] + + instances: List[Any] = [] + for tool_name in sorted(selected): + target_name = next( + ( + registered + for registered in TOOL_CLASS_MAP + if registered.strip().lower() == tool_name + ), + None, + ) + if target_name is None: + logger.warning( + "[%s] Core tool '%s' is not registered", log_prefix, tool_name + ) + continue + instance = AgentToolManager.convert_tool(target_name) + if instance is None: + logger.warning( + "[%s] Failed to instantiate core tool '%s'", log_prefix, tool_name + ) + continue + instances.append(instance) + return instances + + +async def include_core_skills( + requested: Optional[Iterable[str]], + *, + skill_creator: Optional[Any], + default_core_skills: Optional[Iterable[str]] = None, + log_prefix: str = "client", +) -> tuple[Optional[Any], Optional[str]]: + """Build a ``SkillTool`` for skills from the union of the user request + and ``default_core_skills``. + + Returns ``(skill_tool, prompt_section)``. Either may be ``None`` — + callers should treat ``None`` as "drop the skill tool entirely / don't + append a prompt section". + """ + if skill_creator is None: + return None, None + + selected = _pick_requested(requested, default_core_skills) + if not selected: + return None, None + + skill_tool = await skill_creator.create_skill_tool() + if skill_tool is None: + return None, None + + registry = getattr(skill_tool, "_skills_registry", None) + if not isinstance(registry, dict): + return None, None + + filtered = { + name: skill + for name, skill in registry.items() + if name.strip().lower() in selected + } + missing = selected - {name.strip().lower() for name in registry.keys()} + for name in sorted(missing): + logger.warning( + "[%s] Core skill '%s' not found in user registry", log_prefix, name + ) + + if not filtered: + return None, None + + skill_tool._skills_registry = filtered + skill_tool.description = generate_skill_tool_description(list(filtered.values())) + return skill_tool, skill_tool.description + + +async def include_connector_tools( + requested: Optional[str], + *, + connector_tool: Optional[Any], + workspace_manager: Optional[Any] = None, + default_connectors: Optional[Iterable[str]] = None, + log_prefix: str = "client", +) -> List[Any]: + """Instantiate connector tools for the user-requested connector, + falling back to the first entry of ``default_connectors``. + + Connector is a single-slot capability (one connector per agent run), + so unlike tools/skills there's no union — the user's choice wins, and + we only consult ``default_connectors`` when the request is silent. + Returns ``[]`` when nothing is selected, no ``connector_tool`` is + wired, or instantiation fails. + """ + selected = (requested or "").strip().lower() or next( + iter(sorted(_normalize_name_set(default_connectors) or set())), None + ) + if not selected or connector_tool is None: + return [] + + try: + connector_tools = await connector_tool.create_connector_tools( + workspace_manager=workspace_manager, + ) + except Exception as exc: + logger.error( + "[%s] Failed to load connector '%s': %s", + log_prefix, + selected, + exc, + exc_info=True, + ) + return [] + + if connector_tools: + logger.info( + "[%s] Added %d connector tools (%s)", + log_prefix, + len(connector_tools), + selected, + ) + return connector_tools or [] + + +def dedupe_tools(agent_tools: List[Any]) -> List[Any]: + """Drop duplicate tools by case-insensitive name, preserving order.""" + seen: set[str] = set() + out: List[Any] = [] + for tool in agent_tools: + name = _tool_name(tool) + if not name: + out.append(tool) + continue + if name in seen: + continue + seen.add(name) + out.append(tool) + return out + + +# --------------------------------------------------------------------------- +# Client-defined capabilities +# --------------------------------------------------------------------------- + + +def _placeholder_tool_entrypoint(**_: Any) -> str: + """Body of every external-execution Function — never actually invoked.""" + return "This tool is executed by the client runtime." + + +def _build_external_function( + *, + name: str, + display_name: str, + description: str, + parameters: dict[str, Any], +) -> Function: + return Function( + name=name, + description=description, + parameters=parameters, + display_name=display_name, + entrypoint=_placeholder_tool_entrypoint, + skip_entrypoint_processing=True, + external_execution=True, + show_result=True, + ) + + +def build_client_tools( + client_tool_specs: Optional[Iterable[dict[str, Any]]], + *, + log_prefix: str = "client", +) -> List[Function]: + """Build external-execution Functions from ``client_tools`` descriptors. + + Each descriptor must carry ``name`` and ``description``; ``input_schema`` + falls back to an open object schema. ``aliases`` and ``display_name`` are + optional. + """ + if not client_tool_specs: + return [] + + client_tools: List[Function] = [] + for spec in client_tool_specs: + if not isinstance(spec, dict): + continue + name = spec.get("name") + description = spec.get("description") + if not isinstance(name, str) or not name.strip(): + continue + if not isinstance(description, str) or not description.strip(): + continue + + display_name = spec.get("display_name") + if not isinstance(display_name, str) or not display_name.strip(): + display_name = name + + parameters = spec.get("input_schema") + if not isinstance(parameters, dict) or not parameters: + parameters = {"type": "object", "properties": {}, "required": []} + + aliases = spec.get("aliases") or [] + if not isinstance(aliases, list): + aliases = [] + published_names = [name] + [ + a.strip() for a in aliases if isinstance(a, str) and a.strip() + ] + + seen: set[str] = set() + for published_name in published_names: + if published_name in seen: + continue + seen.add(published_name) + client_tools.append( + _build_external_function( + name=published_name, + display_name=display_name, + description=description, + parameters=parameters, + ) + ) + + if not client_tools and any( + isinstance(spec, dict) for spec in client_tool_specs + ): + logger.warning( + "[%s] client_tools provided but no valid descriptors were published", + log_prefix, + ) + return client_tools + + +def build_client_skill_prompt( + client_skill_specs: Optional[Iterable[dict[str, Any]]], + *, + heading: str = "Skills available in the client runtime:", +) -> Optional[str]: + """Render a short skill catalog for inclusion in the system prompt.""" + if not client_skill_specs: + return None + + lines: List[str] = [] + for skill in client_skill_specs: + if not isinstance(skill, dict): + continue + name = skill.get("name") + description = skill.get("description") + if not isinstance(name, str) or not name.strip(): + continue + if not isinstance(description, str) or not description.strip(): + continue + lines.append(f"- {name}: {description}") + + if not lines: + return None + return f"{heading}\n" + "\n".join(lines) diff --git a/src/ii_agent/realtime/handlers/cowork_continue_run.py b/src/ii_agent/realtime/handlers/cowork_continue_run.py new file mode 100644 index 000000000..366b55b46 --- /dev/null +++ b/src/ii_agent/realtime/handlers/cowork_continue_run.py @@ -0,0 +1,228 @@ +"""Handler for cowork_continue_run command.""" + +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from ii_agent.agents.sessions import AgentSessionStore +from ii_agent.agents.types import AgentType +from ii_agent.clients.cowork.factory import cowork_agent_factory +from ii_agent.core.db import get_db_session_local, get_session_factory +from ii_agent.core.logger import logger +from ii_agent.realtime.events.app_events import ( + AgentContinueEvent, + AgentProcessingEvent, + ErrorCode, +) +from ii_agent.realtime.handlers.base import CommandType +from ii_agent.realtime.handlers.continue_run import ContinueRunHandler +from ii_agent.realtime.schemas import CoworkContinueRunContent +from ii_agent.sessions.schemas import SessionInfo + + +class CoworkContinueRunHandler(ContinueRunHandler): + """Handle ``cowork_continue_run`` commands.""" + + _content_type = CoworkContinueRunContent + + def get_command_type(self) -> CommandType: + return CommandType.COWORK_CONTINUE_RUN + + async def handle( + self, + content: CoworkContinueRunContent, + session_info: SessionInfo, + ) -> None: + if session_info.api_version != "v1": + await self._send_error_event( + session_info.id, + error_code=ErrorCode.UNSUPPORTED_API_VERSION, + message="continue_run is only supported for v1 API version", + ) + return + + run_id = content.run_id + confirmed = content.confirmed + user_input = content.user_input + external_tool_results = content.external_tool_results or [] + + await self.send_event( + AgentContinueEvent( + session_id=UUID(str(session_info.id)), + content={ + "message": "Agent continuing...", + "confirmed": confirmed, + "run_id": run_id, + }, + ) + ) + + try: + session_store = AgentSessionStore(session_maker=get_session_factory()) + run_response = await session_store.get_by_run_id( + run_id=run_id, session_id=str(session_info.id) + ) + if not run_response: + await self._send_error_event( + session_info.id, + error_code=ErrorCode.RUN_NOT_FOUND, + message=f"Run {run_id} not found", + ) + return + + run_task_data = await self._load_run_task_data(run_id) + + for tool in run_response.tools_requiring_confirmation: + tool.confirmed = bool(confirmed) + logger.info( + "[cowork] continue confirmation run=%s tool_call_id=%s confirmed=%s", + run_id, + tool.tool_call_id, + confirmed, + ) + + for tool in run_response.tools_requiring_user_input: + if confirmed and user_input: + self._apply_user_input_to_tool(tool, user_input, run_id) + tool.answered = True + else: + tool.answered = False + + self._apply_external_tool_results( + run_response.tools, external_tool_results, run_id + ) + + llm_config = None + if session_info.model_setting_id: + try: + async with get_db_session_local() as db: + llm_config = ( + await self._container.model_setting_service.resolve_config_by_setting_id( + db, setting_id=session_info.model_setting_id + ) + ) + except Exception as exc: + logger.warning( + "[cowork] continue_run model resolution failed: %s", + exc, + ) + + agent = await cowork_agent_factory.create_agent( + user_id=str(session_info.user_id), + session_id=str(session_info.id), + llm_config=llm_config, + agent_type=AgentType(session_info.agent_type) + if session_info.agent_type + else AgentType.COWORK, + session_store=session_store, + metadata=run_task_data.get("metadata"), + system_prompt=run_task_data.get("system_prompt"), + requested_capabilities=run_task_data.get("requested_capabilities"), + skill_creator=self._create_skill_creator(session_info.user_id), + ) + + await self.send_event( + AgentProcessingEvent( + session_id=UUID(str(session_info.id)), + message="Resuming agent execution...", + content={ + "message": "Resuming agent execution...", + "run_id": run_id, + }, + ) + ) + + event_stream = agent.acontinue_run( + run_id=run_response.run_id, + updated_tools=run_response.tools, + stream=True, + stream_events=True, + ) + + await self.process_agent_event_stream( + event_stream, + session_info, + run_id=UUID(run_response.run_id), + is_user_key=llm_config.is_user_model() if llm_config else False, + llm_config=llm_config, + ) + + except ValueError as exc: + logger.error("[cowork] continue_run ValueError: %s", exc) + await self._send_error_event( + session_info.id, + error_code=ErrorCode.VALIDATION_ERROR, + message=str(exc), + ) + except Exception as exc: + logger.error( + "[cowork] continue_run failed: %s", exc, exc_info=True + ) + await self._send_error_event( + session_info.id, + error_code=ErrorCode.EXECUTION_ERROR, + message=f"Failed to continue run: {exc}", + ) + + async def _load_run_task_data(self, run_id: str) -> dict[str, Any]: + """Reload the original ``RunTask.data`` so overrides survive resume.""" + try: + run_task_id = UUID(str(run_id)) + except ValueError: + return {} + + async with get_db_session_local() as db: + run_task = await self._container.run_task_service.get_task_by_id( + db, task_id=run_task_id + ) + + if not run_task or not isinstance(run_task.data, dict): + return {} + return run_task.data + + @staticmethod + def _apply_external_tool_results( + tools: list[Any], + external_tool_results: list[dict[str, Any]], + run_id: str, + ) -> None: + """Inject extension-side tool execution results back into paused tools.""" + if not external_tool_results: + return + + by_id = { + str(item.get("tool_call_id")): item + for item in external_tool_results + if isinstance(item, dict) and item.get("tool_call_id") + } + + for tool in tools: + tool_call_id = getattr(tool, "tool_call_id", None) + if not tool_call_id: + continue + external = by_id.get(str(tool_call_id)) + if not external: + continue + + llm_content = external.get("llm_content") + user_display_content = external.get("user_display_content") + tool.result = ( + llm_content if llm_content is not None else user_display_content + ) + tool.tool_call_error = bool(external.get("is_error")) + + tool_input = external.get("tool_input") + if isinstance(tool_input, dict): + tool.tool_args = tool_input + + tool_name = external.get("tool_name") + if isinstance(tool_name, str) and tool_name.strip(): + tool.tool_name = tool_name + + logger.info( + "[cowork] applied external tool result run=%s tool_call_id=%s error=%s", + run_id, + tool_call_id, + tool.tool_call_error, + ) diff --git a/src/ii_agent/realtime/handlers/cowork_query.py b/src/ii_agent/realtime/handlers/cowork_query.py new file mode 100644 index 000000000..1b5f4d5c7 --- /dev/null +++ b/src/ii_agent/realtime/handlers/cowork_query.py @@ -0,0 +1,174 @@ +"""Handler for cowork_query command with agent overrides.""" + +from __future__ import annotations + +from ii_agent.agents.sandboxes import upload_media_to_sandbox +from ii_agent.agents.sessions import AgentSessionStore +from ii_agent.agents.types import AgentType +from ii_agent.clients.cowork.factory import cowork_agent_factory +from ii_agent.core.db import get_db_session_local, get_session_factory +from ii_agent.core.logger import logger +from ii_agent.files.media import File as UrlFile, Image +from ii_agent.realtime.events.app_events import ErrorCode +from ii_agent.realtime.handlers.base import CommandType +from ii_agent.realtime.handlers.query import UserQueryHandler +from ii_agent.realtime.schemas import CoworkQueryCommandContent +from ii_agent.sessions.schemas import SessionInfo +from ii_agent.sessions.types import AppKind +from ii_agent.settings.llm.schemas import ModelConfig +from ii_agent.tasks.types import RunStatus, TaskType + + +class CoworkQueryHandler(UserQueryHandler): + """Handle ``cowork_query`` commands from the cowork mode.""" + + _content_type = CoworkQueryCommandContent + + def get_command_type(self) -> CommandType: + return CommandType.COWORK_QUERY + + async def handle( + self, + content: CoworkQueryCommandContent, + existing_session: SessionInfo, + ) -> None: + await self._ensure_cowork_app_kind(existing_session.id) + + is_valid, session_info, llm_config = await self.validate_and_update_session( + existing_session, content + ) + if not is_valid or not session_info or not llm_config: + return + + await self._handle_cowork_query(content, session_info, llm_config) + + async def _handle_cowork_query( + self, + query_command: CoworkQueryCommandContent, + session_info: SessionInfo, + llm_config: ModelConfig, + ) -> None: + run_service = self._container.run_task_service + file_service = self._container.file_service + + run_task = None + try: + async with get_db_session_local() as db: + run_task = await run_service.claim_task( + db, + session_id=session_info.id, + task_type=TaskType.AGENT_RUN, + data=query_command.model_dump(), + ) + user_event, _ = await self.create_user_message_event( + session_info, query_command, db, run_id=run_task.id + ) + await db.commit() + await self.send_event(user_event) + except Exception as exc: + logger.error( + "[cowork] Failed to claim task: %s", exc, exc_info=True + ) + await self._send_error_event( + session_id=session_info.id, + error_code=ErrorCode.INTERNAL_ERROR, + message=str(exc), + user_id=session_info.user_id, + ) + return + + try: + session_store = AgentSessionStore(session_maker=get_session_factory()) + agent = await cowork_agent_factory.create_agent( + user_id=str(session_info.user_id), + session_id=str(session_info.id), + llm_config=llm_config, + agent_type=AgentType(session_info.agent_type) + if session_info.agent_type + else AgentType.COWORK, + session_store=session_store, + tool_args=query_command.tool_args, + metadata=query_command.metadata, + system_prompt=query_command.system_prompt, + requested_capabilities=query_command.requested_capabilities, + skill_creator=self._create_skill_creator(session_info.user_id), + ) + + images: list[Image] = [] + files: list[UrlFile] = [] + if query_command.files: + async with get_db_session_local() as db: + images, files = await file_service.prepare_agent_files( + db, + file_ids=query_command.files, + user_id=session_info.user_id, + session_id=session_info.id, + ) + + if images or files: + sandbox_service = self._container.sandbox_service + async with get_db_session_local() as db: + sandbox = await sandbox_service.init_sandbox( + db, + session_id=session_info.id, + user_id=session_info.user_id, + ) + agent.sandbox = sandbox + await sandbox.create_directory(sandbox.upload_path, exist_ok=True) + sandbox_files, sandbox_images = await upload_media_to_sandbox( + sandbox=sandbox, + files=files or [], + images=images or [], + upload_path=sandbox.upload_path, + ) + if sandbox_files: + files = sandbox_files + if sandbox_images: + images = sandbox_images + + event_stream = await agent.arun( + query_command.text, + stream=True, + stream_events=True, + run_id=str(run_task.id), + images=images or None, + files=files or None, + yield_run_output=False, + ) + + await self.process_agent_event_stream( + event_stream, + session_info, + run_id=run_task.id, + is_user_key=llm_config.is_user_model(), + llm_config=llm_config, + ) + except Exception as exc: + logger.opt(exception=True).error( + "[cowork] Error processing query: %s", exc + ) + async with get_db_session_local() as db: + await run_service.transition_status( + db, task_id=run_task.id, to_status=RunStatus.FAILED + ) + await db.commit() + raise + + async def _ensure_cowork_app_kind(self, session_id) -> None: + """Stamp ``app_kind = cowork`` on the session if not set.""" + try: + async with get_db_session_local() as db: + session = await self._container.session_service._session_repo.get_by_id( + db, session_id + ) + if session is None: + return + if session.app_kind != AppKind.COWORK: + session.app_kind = AppKind.COWORK + await db.commit() + except Exception as exc: + logger.warning( + "[cowork] Failed to stamp app_kind on session %s: %s", + session_id, + exc, + ) diff --git a/src/ii_agent/realtime/handlers/factory.py b/src/ii_agent/realtime/handlers/factory.py index a27ceeccc..2146ba906 100644 --- a/src/ii_agent/realtime/handlers/factory.py +++ b/src/ii_agent/realtime/handlers/factory.py @@ -38,6 +38,8 @@ from ii_agent.realtime.handlers.design_save_state import DesignSaveStateHandler from ii_agent.realtime.handlers.design_sync_state import DesignSyncStateHandler from ii_agent.realtime.handlers.slide_deck_sync_state import SlideDeckSyncStateHandler +from ii_agent.realtime.handlers.cowork_query import CoworkQueryHandler +from ii_agent.realtime.handlers.cowork_continue_run import CoworkContinueRunHandler class CommandHandlerFactory: @@ -92,6 +94,9 @@ def _initialize_handlers(self) -> None: CommandType.DESIGN_SAVE_STATE: DesignSaveStateHandler(pubsub=ps, container=ct), CommandType.DESIGN_SYNC_STATE: DesignSyncStateHandler(pubsub=ps, container=ct), CommandType.SLIDE_DECK_SYNC_STATE: SlideDeckSyncStateHandler(pubsub=ps, container=ct), + CommandType.COWORK_QUERY: CoworkQueryHandler(pubsub=ps, container=ct), + CommandType.COWORK_CONTINUE_RUN: CoworkContinueRunHandler(pubsub=ps, container=ct), + } def get_handler(self, command_type: CommandType) -> BaseCommandHandler | None: diff --git a/src/ii_agent/realtime/schemas.py b/src/ii_agent/realtime/schemas.py index e0a2a4b5a..2b9eff1a5 100644 --- a/src/ii_agent/realtime/schemas.py +++ b/src/ii_agent/realtime/schemas.py @@ -58,6 +58,10 @@ class CommandType(StrEnum): APPLE_CHECK_AUTH = "apple_check_auth" SAVE_EXPO_TOKEN = "save_expo_token" + # Cowork mode + COWORK_QUERY = "cowork_query" + COWORK_CONTINUE_RUN = "cowork_continue_run" + # --------------------------------------------------------------------------- # Base empty content (shared fields for no-payload commands) @@ -418,6 +422,28 @@ class SlideDeckSyncStateContent(BaseModel): model_config = ConfigDict(extra="allow") +# --------------------------------------------------------------------------- +# Cowork mode content models +# --------------------------------------------------------------------------- + +class CoworkQueryCommandContent(BaseCommandQuery): + """Payload for the ``cowork_query`` command.""" + + command: Literal[CommandType.COWORK_QUERY] = CommandType.COWORK_QUERY + system_prompt: str | None = None + requested_capabilities: dict[str, Any] | None = None + + +class CoworkContinueRunContent(BaseModel): + """Payload for cowork_continue_run command.""" + + command: Literal[CommandType.COWORK_CONTINUE_RUN] = CommandType.COWORK_CONTINUE_RUN + run_id: str + confirmed: bool + user_input: dict[str, str] = {} + external_tool_results: list[dict[str, Any]] | None = None + + # --------------------------------------------------------------------------- # Discriminated union of all command content types # --------------------------------------------------------------------------- @@ -425,11 +451,13 @@ class SlideDeckSyncStateContent(BaseModel): CommandContent = Annotated[ Union[ QueryCommandContent, + CoworkQueryCommandContent, PlanCommandContent, InitAgentContent, EnhancePromptContent, StartForkContent, ContinueRunContent, + CoworkContinueRunContent, PublishProjectContent, CloudRunPublishContent, SaveEnvContent, diff --git a/src/ii_agent/sessions/types.py b/src/ii_agent/sessions/types.py index dabd03280..844b62e06 100644 --- a/src/ii_agent/sessions/types.py +++ b/src/ii_agent/sessions/types.py @@ -16,3 +16,4 @@ class AppKind(StrEnum): AGENT = "agent" CHAT = "chat" + COWORK = "cowork"