diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b0eb25c688..6eb787173b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -715,9 +715,13 @@ jobs: cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh "$TARGET" + - name: Bundle Windows MeshLLM runtime dependencies + shell: pwsh + run: ./scripts/bundle-windows-mesh-runtime-deps.ps1 + - name: Build Windows NSIS installer (unsigned) shell: bash - run: cd desktop && pnpm tauri build --verbose --target "$TARGET" --bundles nsis --config src-tauri/tauri.release.conf.json + run: cd desktop && pnpm tauri build --verbose --target "$TARGET" --bundles nsis --features mesh-llm --config src-tauri/tauri.release.conf.json env: BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }} BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json diff --git a/.github/workflows/windows-canary.yml b/.github/workflows/windows-canary.yml index 7093efd2dc6..85979d3166a 100644 --- a/.github/workflows/windows-canary.yml +++ b/.github/workflows/windows-canary.yml @@ -5,7 +5,7 @@ name: Windows Canary # only as a short-lived GitHub Actions artifact for explicit testing. # # Design notes vs. signed-macos-canary.yml: -# - No mesh-llm: release-windows doesn't build it. +# - Mesh-llm is enabled to match stock Windows release behavior. # - pnpm store restore/save pattern mirrors ci.yml:149-196. on: workflow_dispatch: @@ -124,9 +124,13 @@ jobs: ~/.cargo/git target desktop/src-tauri/target - !desktop/src-tauri/target/**/release/bundle + !desktop/src-tauri/target/**/release/bundle/** key: ${{ steps.rust_cache_key.outputs.key }} + - name: Remove restored bundle outputs + shell: bash + run: rm -rf desktop/src-tauri/target/**/release/bundle + - name: Generate non-updating bundle config shell: bash run: | @@ -144,24 +148,31 @@ jobs: cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh "$TARGET" + - name: Bundle Windows MeshLLM runtime dependencies + shell: pwsh + run: ./scripts/bundle-windows-mesh-runtime-deps.ps1 + - name: Build Windows NSIS installer (unsigned) shell: bash - run: cd desktop && pnpm tauri build --target "$TARGET" --bundles nsis --config src-tauri/tauri.canary.conf.json + run: cd desktop && pnpm tauri build --target "$TARGET" --bundles nsis --features mesh-llm --config src-tauri/tauri.canary.conf.json env: CMAKE_POLICY_VERSION_MINIMUM: "3.5" - name: Locate NSIS installer id: artifact shell: bash + env: + VERSION: ${{ steps.version.outputs.version }} run: | set -euo pipefail BUNDLE_DIR="desktop/src-tauri/target/${TARGET}/release/bundle" - EXE=$(find "$BUNDLE_DIR/nsis" -name '*.exe' -type f | head -1) - if [[ -z "$EXE" ]]; then - echo "::error::No NSIS installer found in $BUNDLE_DIR/nsis" + mapfile -t EXES < <(find "$BUNDLE_DIR/nsis" -name "*${VERSION}*.exe" -type f | sort) + if [[ ${#EXES[@]} -ne 1 ]]; then + echo "::error::Expected exactly one NSIS installer for version $VERSION in $BUNDLE_DIR/nsis; found ${#EXES[@]}" + find "$BUNDLE_DIR/nsis" -name '*.exe' -type f -print || true exit 1 fi - echo "exe=$EXE" >> "$GITHUB_OUTPUT" + echo "exe=${EXES[0]}" >> "$GITHUB_OUTPUT" - name: Upload Windows canary installer uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -187,7 +198,7 @@ jobs: ~/.cargo/git target desktop/src-tauri/target - !desktop/src-tauri/target/**/release/bundle + !desktop/src-tauri/target/**/release/bundle/** key: ${{ steps.rust_cache_key.outputs.key }} - name: Save pnpm store cache diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 1f3cac3df49..7b2b478a9ee 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -688,6 +688,7 @@ fn openai_body( effort: Option, ) -> Value { let mut messages: Vec = vec![json!({ "role": "system", "content": system_prompt })]; + let include_tool_images = openai_chat_tool_images_enabled(cfg); // Images returned from tool calls ride on a trailing `role:"user"` // message because OpenAI Chat's `role:"tool"` content is text-only. We // batch them across a run of adjacent ToolResult items so that all @@ -751,8 +752,10 @@ fn openai_body( HistoryItem::ToolResult(r) => { messages.push(json!({ "role": "tool", "tool_call_id": r.provider_id, - "content": openai_tool_text_content(&r.content) })); - pending_images.extend(openai_image_user_content(&r.content)); + "content": openai_tool_text_content(&r.content, include_tool_images) })); + if include_tool_images { + pending_images.extend(openai_image_user_content(&r.content)); + } } } } @@ -778,15 +781,27 @@ fn openai_body( body } -fn openai_tool_text_content(content: &[ToolResultContent]) -> String { +fn openai_chat_tool_images_enabled(cfg: &Config) -> bool { + let base_url = cfg.base_url.trim().trim_end_matches('/'); + !matches!( + base_url, + "http://127.0.0.1:9337/v1" | "http://localhost:9337/v1" + ) +} + +fn openai_tool_text_content(content: &[ToolResultContent], images_forwarded: bool) -> String { let mut parts = Vec::new(); for c in content { match c { ToolResultContent::Text(text) => parts.push(text.clone()), - ToolResultContent::Image { data, mime_type } => parts.push(format!( + ToolResultContent::Image { data, mime_type } if images_forwarded => parts.push(format!( "This tool result included an image ({mime_type}, {} base64 bytes) that is provided in the next user message.", data.len() )), + ToolResultContent::Image { data, mime_type } => parts.push(format!( + "This tool result included an image ({mime_type}, {} base64 bytes) that was omitted because this OpenAI-compatible endpoint does not support image inputs.", + data.len() + )), } } parts.join("\n") @@ -852,7 +867,7 @@ fn responses_body( input.push(json!({ "type": "function_call_output", "call_id": r.provider_id, - "output": openai_tool_text_content(&r.content), + "output": openai_tool_text_content(&r.content, true), })); // Responses takes images as `input_image` parts on a user message. let images: Vec = r @@ -3278,6 +3293,24 @@ mod tests { ); } + #[test] + fn openai_tool_result_omits_followup_image_for_relay_mesh() { + let mut cfg = cfg(Provider::OpenAi); + cfg.base_url = "http://127.0.0.1:9337/v1".into(); + let body = openai_body(&cfg, "system", &image_history(), &[], "auto", None); + let messages = body["messages"].as_array().unwrap(); + assert_eq!( + messages.len(), + 4, + "relay mesh must not receive image_url messages" + ); + assert_eq!(messages[3]["role"], "tool"); + let tool_content = messages[3]["content"].as_str().unwrap(); + assert!(tool_content.contains("included an image")); + assert!(tool_content.contains("was omitted")); + assert!(!body.to_string().contains("image_url")); + } + /// Regression for Databricks model serving (and any OpenAI-Chat frontend /// that translates to Anthropic on the way to the model). Parallel tool /// calls where one or more return images previously produced an diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 2ec773356e7..e4b2581cf3c 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1168,6 +1168,7 @@ dependencies = [ "uuid", "webkit2gtk", "window-vibrancy", + "windows 0.62.2", "windows-sys 0.61.2", "zeroize", "zip 8.6.0", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 31092de99a9..80f7f3d0c15 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -63,7 +63,8 @@ user-idle = { version = "0.6", default-features = false } plist = "1" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation"] } +windows = { version = "0.62", features = ["Win32_Graphics_Dxgi"] } +windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_LibraryLoader", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation"] } keyring = { version = "3.6.3", default-features = false, features = ["windows-native", "vendored"], optional = true } user-idle = { version = "0.6", default-features = false } diff --git a/desktop/src-tauri/resources/mesh-llm/windows-x86_64/.gitignore b/desktop/src-tauri/resources/mesh-llm/windows-x86_64/.gitignore new file mode 100644 index 00000000000..6a7461313bb --- /dev/null +++ b/desktop/src-tauri/resources/mesh-llm/windows-x86_64/.gitignore @@ -0,0 +1 @@ +*.dll diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index dd61fc9398a..a45213cdae8 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -1,5 +1,5 @@ use nostr::{Keys, ToBech32}; -use tauri::{AppHandle, State}; +use tauri::{AppHandle, Manager, State}; use crate::{ app_state::AppState, @@ -20,6 +20,12 @@ use crate::{ /// Read the workspace owner pubkey without holding the lock. Used to populate `BUZZ_ACP_AGENT_OWNER` /// as a fallback for legacy agent records that have no NIP-OA `auth_tag`. +#[cfg(target_os = "windows")] +const WINDOWS_AGENT_COMMAND_STACK_SIZE: usize = 128 * 1024 * 1024; + +#[cfg(not(target_os = "windows"))] +const WINDOWS_AGENT_COMMAND_STACK_SIZE: usize = 8 * 1024 * 1024; + pub(super) fn workspace_owner_hex(state: &AppState) -> Result { let keys = state.keys.lock().map_err(|e| e.to_string())?; Ok(keys.public_key().to_hex()) @@ -565,6 +571,30 @@ pub async fn list_managed_agents(app: AppHandle) -> Result Result { + let (sender, receiver) = tokio::sync::oneshot::channel(); + let app_for_thread = app.clone(); + std::thread::Builder::new() + .name("buzz-agent-create".to_string()) + .stack_size(WINDOWS_AGENT_COMMAND_STACK_SIZE) + .spawn(move || { + let result = tauri::async_runtime::block_on(async move { + let app_for_inner = app_for_thread.clone(); + let state = app_for_thread.state::(); + create_managed_agent_inner(input, app_for_inner, state).await + }); + let _ = sender.send(result); + }) + .map_err(|error| format!("failed to spawn agent create thread: {error}"))?; + + receiver + .await + .map_err(|error| format!("agent create thread exited before returning: {error}"))? +} + +async fn create_managed_agent_inner( + input: CreateManagedAgentRequest, + app: AppHandle, state: State<'_, AppState>, ) -> Result { let name = input.name.trim().to_string(); @@ -1066,6 +1096,30 @@ pub async fn create_managed_agent( pub async fn start_managed_agent( pubkey: String, app: AppHandle, +) -> Result { + let (sender, receiver) = tokio::sync::oneshot::channel(); + let app_for_thread = app.clone(); + std::thread::Builder::new() + .name("buzz-agent-start".to_string()) + .stack_size(WINDOWS_AGENT_COMMAND_STACK_SIZE) + .spawn(move || { + let result = tauri::async_runtime::block_on(async move { + let app_for_inner = app_for_thread.clone(); + let state = app_for_thread.state::(); + start_managed_agent_inner(pubkey, app_for_inner, state).await + }); + let _ = sender.send(result); + }) + .map_err(|error| format!("failed to spawn agent start thread: {error}"))?; + + receiver + .await + .map_err(|error| format!("agent start thread exited before returning: {error}"))? +} + +async fn start_managed_agent_inner( + pubkey: String, + app: AppHandle, state: State<'_, AppState>, ) -> Result { // Snapshot the workspace owner pubkey for the legacy auth_tag fallback. diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index 7356cd7fc0c..92f5a7ed4af 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -1,4 +1,7 @@ +use std::collections::HashSet; +use std::io::Write; use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; use sha2::{Digest, Sha256}; use tauri::{AppHandle, Manager, State}; @@ -37,6 +40,12 @@ fn one_shot_restart_checkpoint(config: &MeshSharingConfig) -> MeshSharingConfig checkpoint } +#[derive(Debug, Default, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct MeshDiagnosticsConfig { + debug_logging_enabled: bool, +} + fn mesh_sharing_config_path(app: &AppHandle) -> Result { Ok(app .path() @@ -45,6 +54,111 @@ fn mesh_sharing_config_path(app: &AppHandle) -> Result { .join("mesh-sharing.json")) } +fn mesh_diagnostics_config_path(app: &AppHandle) -> Result { + Ok(app + .path() + .app_data_dir() + .map_err(|error| format!("failed to resolve app data dir: {error}"))? + .join("mesh-diagnostics.json")) +} + +fn load_mesh_diagnostics_config(app: &AppHandle) -> MeshDiagnosticsConfig { + let Ok(path) = mesh_diagnostics_config_path(app) else { + return MeshDiagnosticsConfig::default(); + }; + std::fs::read(path) + .ok() + .and_then(|payload| serde_json::from_slice(&payload).ok()) + .unwrap_or_default() +} + +fn save_mesh_diagnostics_config( + app: &AppHandle, + config: &MeshDiagnosticsConfig, +) -> Result<(), String> { + let path = mesh_diagnostics_config_path(app)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + format!("failed to create mesh diagnostics config directory: {error}") + })?; + } + let payload = serde_json::to_vec_pretty(config) + .map_err(|error| format!("failed to encode mesh diagnostics config: {error}"))?; + crate::managed_agents::atomic_write_json(&path, &payload) +} + +static MESH_DEBUG_LOG_LOCK: OnceLock> = OnceLock::new(); + +fn env_mesh_debug_logging_enabled() -> bool { + std::env::var("BUZZ_MESH_DEBUG_LOG") + .map(|value| { + matches!( + value.to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) + .unwrap_or(false) +} + +fn is_mesh_debug_logging_enabled(app: &AppHandle) -> bool { + env_mesh_debug_logging_enabled() || load_mesh_diagnostics_config(app).debug_logging_enabled +} + +pub(crate) fn append_mesh_debug_log(app: &AppHandle, message: impl AsRef) { + if !is_mesh_debug_logging_enabled(app) { + return; + } + + let lock = MESH_DEBUG_LOG_LOCK.get_or_init(|| Mutex::new(())); + let _guard = lock.lock().ok(); + + let mut paths = vec![std::env::temp_dir().join("buzz-mesh-debug.log")]; + if let Ok(data_dir) = app.path().app_data_dir() { + paths.push(data_dir.join("mesh-debug.log")); + } + + let line = format!("{} {}\n", chrono::Utc::now().to_rfc3339(), message.as_ref()); + for path in paths { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + { + let _ = file.write_all(line.as_bytes()); + let _ = file.flush(); + } + } +} + +#[tauri::command] +pub async fn mesh_debug_log(app: AppHandle, message: String) -> CmdResult<()> { + append_mesh_debug_log(&app, format!("frontend {message}")); + Ok(()) +} + +#[tauri::command] +pub async fn mesh_debug_logging_enabled(app: AppHandle) -> CmdResult { + Ok(is_mesh_debug_logging_enabled(&app)) +} + +#[tauri::command] +pub async fn set_mesh_debug_logging_enabled(app: AppHandle, enabled: bool) -> CmdResult { + save_mesh_diagnostics_config( + &app, + &MeshDiagnosticsConfig { + debug_logging_enabled: enabled, + }, + )?; + append_mesh_debug_log( + &app, + format!("mesh diagnostic logging setting changed enabled={enabled}"), + ); + Ok(is_mesh_debug_logging_enabled(&app)) +} + fn save_mesh_sharing_config(app: &AppHandle, config: &MeshSharingConfig) -> Result<(), String> { let path = mesh_sharing_config_path(app)?; if let Some(parent) = path.parent() { @@ -67,6 +181,306 @@ fn load_mesh_sharing_config(app: &AppHandle) -> Result } } +#[cfg(target_os = "windows")] +const WINDOWS_MESH_RUNTIME_DEP_DLLS: &[&str] = &[ + "libgcc_s_seh-1.dll", + "libstdc++-6.dll", + "libgomp-1.dll", + "libwinpthread-1.dll", +]; + +#[cfg(target_os = "windows")] +fn windows_mesh_runtime_dependency_dirs(app: &AppHandle) -> Vec { + let mut dirs = Vec::new(); + if let Ok(resource_dir) = app.path().resource_dir() { + dirs.push(resource_dir.join("mesh-llm").join("windows-x86_64")); + dirs.push( + resource_dir + .join("resources") + .join("mesh-llm") + .join("windows-x86_64"), + ); + } + dirs.into_iter() + .filter(|dir| { + WINDOWS_MESH_RUNTIME_DEP_DLLS + .iter() + .all(|name| dir.join(name).is_file()) + }) + .collect() +} + +#[cfg(target_os = "windows")] +static WINDOWS_MESH_DLL_DIRS: OnceLock>> = OnceLock::new(); + +#[cfg(target_os = "windows")] +fn register_windows_dll_directory(app: &AppHandle, dir: &std::path::Path) { + use std::os::windows::ffi::OsStrExt; + + let canonical = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()); + let seen = WINDOWS_MESH_DLL_DIRS.get_or_init(|| Mutex::new(HashSet::new())); + if let Ok(mut seen) = seen.lock() { + if !seen.insert(canonical.clone()) { + return; + } + } + + let wide: Vec = canonical + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let cookie = + unsafe { windows_sys::Win32::System::LibraryLoader::AddDllDirectory(wide.as_ptr()) }; + append_mesh_debug_log( + app, + format!( + "registered windows DLL directory dir={} ok={}", + canonical.display(), + !cookie.is_null() + ), + ); +} + +#[cfg(target_os = "windows")] +fn existing_child_dir(parent: impl AsRef, child: &str) -> Option { + let dir = parent.as_ref().join(child); + dir.is_dir().then_some(dir) +} + +#[cfg(target_os = "windows")] +fn windows_mesh_gpu_sdk_dll_dirs_from( + env_vars: impl IntoIterator, + program_files: Option, +) -> Vec { + let mut dirs = Vec::new(); + let env_vars: Vec<_> = env_vars.into_iter().collect(); + + for (key, value) in &env_vars { + let key = key.to_string_lossy().to_ascii_uppercase(); + if key == "HIP_PATH" || key.starts_with("HIP_PATH_") || key == "ROCM_PATH" { + if let Some(dir) = existing_child_dir(value, "bin") { + dirs.push(dir); + } + } + } + if let Some(program_files) = program_files.as_ref() { + let rocm_root = PathBuf::from(program_files).join("AMD").join("ROCm"); + if let Ok(entries) = std::fs::read_dir(rocm_root) { + for entry in entries.flatten() { + if let Some(dir) = existing_child_dir(entry.path(), "bin") { + dirs.push(dir); + } + } + } + } + + for (key, value) in &env_vars { + let key = key.to_string_lossy().to_ascii_uppercase(); + if key == "CUDA_PATH" || key.starts_with("CUDA_PATH_V") { + if let Some(dir) = existing_child_dir(value, "bin") { + dirs.push(dir); + } + } + } + if let Some(program_files) = program_files.as_ref() { + let cuda_root = PathBuf::from(program_files) + .join("NVIDIA GPU Computing Toolkit") + .join("CUDA"); + if let Ok(entries) = std::fs::read_dir(cuda_root) { + for entry in entries.flatten() { + if let Some(dir) = existing_child_dir(entry.path(), "bin") { + dirs.push(dir); + } + } + } + } + + let mut seen = HashSet::new(); + dirs.into_iter() + .filter(|dir| seen.insert(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.clone()))) + .collect() +} + +#[cfg(target_os = "windows")] +fn windows_mesh_gpu_sdk_dll_dirs() -> Vec { + windows_mesh_gpu_sdk_dll_dirs_from(std::env::vars_os(), std::env::var_os("ProgramFiles")) +} + +#[cfg(target_os = "windows")] +fn windows_mesh_native_runtime_version() -> &'static str { + mesh_llm_host_runtime::VERSION +} + +#[cfg(target_os = "windows")] +fn windows_mesh_native_runtime_lib_dirs_from( + native_root: &std::path::Path, + runtime_version: &str, +) -> Vec { + let mut lib_dirs = Vec::new(); + let version_root = native_root.join(runtime_version); + if let Ok(runtime_dirs) = std::fs::read_dir(version_root) { + for runtime_dir in runtime_dirs.flatten() { + let lib_dir = runtime_dir.path().join("lib"); + if lib_dir.is_dir() { + lib_dirs.push(lib_dir); + } + } + } + lib_dirs.sort(); + lib_dirs +} + +#[cfg(target_os = "windows")] +fn windows_mesh_dll_registration_order( + runtime_lib_dirs: Vec, + dependency_dirs: Vec, + gpu_sdk_dirs: Vec, +) -> Vec { + let mut registered_dirs = runtime_lib_dirs; + registered_dirs.extend(dependency_dirs); + registered_dirs.extend(gpu_sdk_dirs); + + let mut seen = HashSet::new(); + registered_dirs + .into_iter() + .filter(|dir| { + dir.is_dir() && seen.insert(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.clone())) + }) + .collect() +} + +#[cfg(target_os = "windows")] +fn prepare_windows_mesh_runtime_dependencies(app: &AppHandle) { + let dependency_dirs = windows_mesh_runtime_dependency_dirs(app); + if dependency_dirs.is_empty() { + append_mesh_debug_log(app, "windows mesh runtime dependency resources not found"); + } + + let runtime_version = windows_mesh_native_runtime_version(); + let runtime_lib_dirs = dirs::data_local_dir() + .map(|local_data_dir| { + windows_mesh_native_runtime_lib_dirs_from( + &local_data_dir.join("mesh-llm").join("native-runtimes"), + runtime_version, + ) + }) + .unwrap_or_default(); + if runtime_lib_dirs.is_empty() { + append_mesh_debug_log( + app, + format!( + "no MeshLLM native runtime lib dirs found for current version {runtime_version}" + ), + ); + } + + for lib_dir in &runtime_lib_dirs { + for dependency_dir in &dependency_dirs { + for name in WINDOWS_MESH_RUNTIME_DEP_DLLS { + let src = dependency_dir.join(name); + let dst = lib_dir.join(name); + // Runtime archives are authoritative for DLLs they ship; + // Buzz's bundle only fills gaps (for example CUDA + // archives that lack MinGW support DLLs). Do not + // replace archive-provided DLLs with runner-local + // MinGW copies, whose version depends on whether the + // bundler bootstrapped via MSYS2 or Chocolatey. + if dst.is_file() { + append_mesh_debug_log( + app, + format!( + "windows mesh runtime dependency already present; not replacing file={}", + dst.display() + ), + ); + continue; + } + match std::fs::copy(&src, &dst) { + Ok(_) => append_mesh_debug_log( + app, + format!( + "copied windows mesh runtime dependency src={} dst={}", + src.display(), + dst.display() + ), + ), + Err(error) => append_mesh_debug_log( + app, + format!( + "failed to copy windows mesh runtime dependency src={} dst={} error={}", + src.display(), + dst.display(), + error + ), + ), + } + } + } + } + + let registered_dirs = windows_mesh_dll_registration_order( + runtime_lib_dirs, + dependency_dirs, + windows_mesh_gpu_sdk_dll_dirs(), + ); + + let mut path_entries: Vec = registered_dirs.clone(); + if let Some(existing) = std::env::var_os("PATH") { + path_entries.extend(std::env::split_paths(&existing)); + } + if let Ok(joined) = std::env::join_paths(path_entries) { + std::env::set_var("PATH", joined); + } + for dir in ®istered_dirs { + register_windows_dll_directory(app, dir); + } + append_mesh_debug_log( + app, + format!( + "prepared windows mesh runtime dependency dirs={}", + registered_dirs + .iter() + .map(|dir| dir.display().to_string()) + .collect::>() + .join(";") + ), + ); +} + +#[cfg(not(target_os = "windows"))] +fn prepare_windows_mesh_runtime_dependencies(_app: &AppHandle) {} + +fn mesh_runtime_load_error_needs_windows_dependency_retry(error: &anyhow::Error) -> bool { + let error = format!("{error:#}"); + error.contains("LoadLibraryExW failed") || error.contains("OS error 126") +} + +async fn start_mesh_runtime_with_windows_dependency_retry( + app: &AppHandle, + request: mesh_llm::StartMeshNodeRequest, +) -> anyhow::Result { + #[cfg(not(target_os = "windows"))] + let _ = app; + #[cfg(target_os = "windows")] + let retry_request = request.clone(); + match mesh_llm::DesktopMeshRuntime::start(request).await { + Ok(started) => Ok(started), + #[cfg(target_os = "windows")] + Err(error) if mesh_runtime_load_error_needs_windows_dependency_retry(&error) => { + append_mesh_debug_log( + app, + format!( + "DesktopMeshRuntime::start load failed; refreshing Windows DLL directories before one retry: {error:#}" + ), + ); + prepare_windows_mesh_runtime_dependencies(app); + mesh_llm::DesktopMeshRuntime::start(retry_request).await + } + Err(error) => Err(error), + } +} + const RELAY_MESH_RUNTIME_NO_TARGET: &str = "Buzz shared compute requires a live serving member; start serving the selected model on a member, then try again"; @@ -357,6 +771,7 @@ pub(crate) async fn restore_mesh_sharing(app: &AppHandle, state: &AppState) -> C if runtime.is_some() { return Ok(()); } + prepare_windows_mesh_runtime_dependencies(app); if config.start_on_next_launch { // Consume a role-switch request before doing any potentially long model // work. If Buzz exits during that work, the next launch stays stopped. @@ -375,7 +790,7 @@ pub(crate) async fn restore_mesh_sharing(app: &AppHandle, state: &AppState) -> C relay_url: Some(relay_url), trusted_owner_ids: Some(trusted_owner_ids), }; - let started = mesh_llm::DesktopMeshRuntime::start(request) + let started = start_mesh_runtime_with_windows_dependency_retry(app, request) .await .map_err(|error| format!("failed to restore Share Compute: {error:#}"))?; // Install the restored runtime immediately: it is tracked by AppState from @@ -404,10 +819,58 @@ pub(crate) async fn restore_mesh_sharing(app: &AppHandle, state: &AppState) -> C #[tauri::command] pub async fn mesh_start_node( + app: AppHandle, + request: mesh_llm::StartMeshNodeRequest, +) -> CmdResult { + append_mesh_debug_log( + &app, + format!( + "mesh_start_node dispatch received mode={:?} model_id={:?} stack={}", + request.mode, + request.model_id, + std::env::var("MESH_TOKIO_STACK_SIZE").unwrap_or_else(|_| "unset".to_string()) + ), + ); + + // Keep Tauri's generated command future tiny. The real mesh start future is + // deep enough to overflow Windows stacks before the command body can log; + // run it on an explicit large-stack OS thread and await only the result. + let (sender, receiver) = tokio::sync::oneshot::channel(); + let app_for_thread = app.clone(); + std::thread::Builder::new() + .name("buzz-mesh-start".to_string()) + .stack_size(crate::mesh_llm::MESH_WORKER_STACK_SIZE) + .spawn(move || { + let result = tauri::async_runtime::block_on(async move { + let app_for_inner = app_for_thread.clone(); + let state = app_for_thread.state::(); + mesh_start_node_inner(app_for_inner, state, request).await + }); + let _ = sender.send(result); + }) + .map_err(|error| format!("failed to spawn mesh start thread: {error}"))?; + + receiver + .await + .map_err(|error| format!("mesh start thread exited before returning: {error}"))? +} + +async fn mesh_start_node_inner( app: AppHandle, state: State<'_, AppState>, mut request: mesh_llm::StartMeshNodeRequest, ) -> CmdResult { + prepare_windows_mesh_runtime_dependencies(&app); + append_mesh_debug_log( + &app, + format!( + "mesh_start_node_inner requested mode={:?} model_id={:?} stack={}", + request.mode, + request.model_id, + std::env::var("MESH_TOKIO_STACK_SIZE").unwrap_or_else(|_| "unset".to_string()) + ), + ); + let relay_url = relay::relay_ws_url_with_override(&state); request.relay_url = Some(relay_url.clone()); if let Some(model_id) = request.model_id.as_mut() { @@ -437,7 +900,11 @@ pub async fn mesh_start_node( return restart_to_share(&app, config); } MeshStartPlan::RejectOccupied => { - return Err("mesh node is already running".to_string()); + append_mesh_debug_log( + &app, + "start requested while mesh node already running; returning current status", + ); + return existing.status().await.map_err(|error| error.to_string()); } MeshStartPlan::Start => {} } @@ -447,6 +914,7 @@ pub async fn mesh_start_node( // Frontend requests never carry a roster. Resolve it and the bootstrap // endpoint from one snapshot so UI startup does not repeat relay probes. if request.trusted_owner_ids.is_none() || request.join_token.is_none() { + append_mesh_debug_log(&app, "resolving Buzz mesh startup metadata"); let (trusted_owner_ids, join_token) = resolve_buzz_mesh_startup_at(&state, &relay_url).await; request.trusted_owner_ids.get_or_insert(trusted_owner_ids); @@ -454,6 +922,16 @@ pub async fn mesh_start_node( request.join_token = join_token; } } + append_mesh_debug_log( + &app, + format!( + "trusted owner ids resolved count={}", + request + .trusted_owner_ids + .as_ref() + .map_or(0, std::vec::Vec::len) + ), + ); request.mesh_name = Some(buzz_mesh_name_for_relay(&relay_url)); let mut runtime = state.mesh_llm_runtime.lock().await; @@ -469,7 +947,13 @@ pub async fn mesh_start_node( return restart_to_share(&app, config); } if plan == MeshStartPlan::RejectOccupied { - return Err("mesh node is already running".to_string()); + if let Some(existing) = runtime.as_ref() { + append_mesh_debug_log( + &app, + "start requested while mesh node already running; returning current status", + ); + return existing.status().await.map_err(|error| error.to_string()); + } } if let Some(config) = sharing_config.as_ref() { @@ -481,9 +965,21 @@ pub async fn mesh_start_node( save_mesh_sharing_config(&app, &pending_new_start_checkpoint(config))?; } - let started = mesh_llm::DesktopMeshRuntime::start(request) - .await - .map_err(|error| format!("{error:#}"))?; + append_mesh_debug_log(&app, "starting DesktopMeshRuntime"); + let started = match start_mesh_runtime_with_windows_dependency_retry(&app, request).await { + Ok(started) => { + append_mesh_debug_log(&app, "DesktopMeshRuntime::start returned ok"); + started + } + Err(error) => { + append_mesh_debug_log( + &app, + format!("DesktopMeshRuntime::start returned error: {error:#}"), + ); + return Err(format!("{error:#}")); + } + }; + append_mesh_debug_log(&app, "probing mesh node status"); let status = match started.status().await { Ok(status) => status, Err(error) => { @@ -493,6 +989,10 @@ pub async fn mesh_start_node( "buzz-mesh: started node status failed and cleanup was incomplete: {cleanup_error:#}" ); } + append_mesh_debug_log( + &app, + format!("mesh node started but status probe failed: {error:#}; restarting"), + ); // The handle was never installed into AppState, so shutdown cannot // see it again. Restart even when stop reported success: the // process boundary guarantees native :9337/:3131 listeners cannot @@ -758,9 +1258,19 @@ pub(crate) async fn ensure_relay_mesh_for_record( } }; + prepare_windows_mesh_runtime_dependencies(app); // No serving configuration exists, so this is a genuine consumer-only // start. A configured serving machine is restored above and never reaches // this client fallback. + // Serve→Client re-arm transition (micspiral review #3, intentional-by-design): + // if the dead ingress belonged to a *serve* node with running consumer + // agents, this re-arms it as a Client (`MeshNodeMode::Client`). That is the + // correct/safe recovery here — config-backed serve restoration is + // `restore_mesh_sharing`'s job (`MeshNodeMode::Serve`), and + // `ensure_client_node_for_model` reuses any live runtime of *either* mode + // (the router resolves per-request), so it only cold-starts a Client when + // there is genuinely no runtime. Falling back to Client if a serve node + // crashed under local pressure is a desirable fail-safe, not a regression. ensure_client_node_for_model(&state, model_id, Some(target.endpoint_addr)).await?; wait_for_mesh_inference(model_id).await } @@ -806,12 +1316,27 @@ pub async fn mesh_stop_node( } #[tauri::command] -pub async fn mesh_node_status(state: State<'_, AppState>) -> CmdResult { +pub async fn mesh_node_status( + app: AppHandle, + state: State<'_, AppState>, +) -> CmdResult { + append_mesh_debug_log(&app, "mesh_node_status requested"); let runtime = state.mesh_llm_runtime.lock().await; - match runtime.as_ref() { + let result = match runtime.as_ref() { Some(runtime) => runtime.status().await.map_err(|error| error.to_string()), None => Ok(mesh_llm::stopped_status()), + }; + match &result { + Ok(status) => append_mesh_debug_log( + &app, + format!( + "mesh_node_status returned state={:?} mode={:?} model_id={:?} health={:?}", + status.state, status.mode, status.model_id, status.health + ), + ), + Err(error) => append_mesh_debug_log(&app, format!("mesh_node_status error: {error}")), } + result } /// Read-only host-side usage: who/what is using the compute this machine is @@ -819,27 +1344,51 @@ pub async fn mesh_node_status(state: State<'_, AppState>) -> CmdResult, ) -> CmdResult { + append_mesh_debug_log(&app, "mesh_serving_usage requested"); let runtime = state.mesh_llm_runtime.lock().await; - match runtime.as_ref() { + let result = match runtime.as_ref() { Some(runtime) => runtime.serving_usage().await.map_err(|e| e.to_string()), None => Ok(mesh_llm::MeshServingUsage::default()), + }; + match &result { + Ok(usage) => append_mesh_debug_log( + &app, + format!( + "mesh_serving_usage returned inflight={} requests_served={} remote_attempts={} endpoint_attempts={}", + usage.inflight, usage.requests_served, usage.remote_attempts, usage.endpoint_attempts + ), + ), + Err(error) => append_mesh_debug_log(&app, format!("mesh_serving_usage error: {error}")), } + result } #[tauri::command] pub async fn mesh_installed_models( + app: AppHandle, state: State<'_, AppState>, ) -> CmdResult> { + append_mesh_debug_log(&app, "mesh_installed_models requested"); let runtime = state.mesh_llm_runtime.lock().await; - if let Some(runtime) = runtime.as_ref() { - return runtime + let result = if let Some(runtime) = runtime.as_ref() { + runtime .installed_models() .await - .map_err(|error| error.to_string()); + .map_err(|error| error.to_string()) + } else { + Ok(Vec::new()) + }; + match &result { + Ok(models) => append_mesh_debug_log( + &app, + format!("mesh_installed_models returned count={}", models.len()), + ), + Err(error) => append_mesh_debug_log(&app, format!("mesh_installed_models error: {error}")), } - Ok(Vec::new()) + result } /// Hardware-aware curated model catalog for the Share-compute picker: the @@ -847,10 +1396,24 @@ pub async fn mesh_installed_models( /// ranked by fit with installed-state flags. Runs the hardware survey + /// HF-cache scan off the async runtime (both do blocking I/O). #[tauri::command] -pub async fn mesh_model_catalog() -> CmdResult { - tokio::task::spawn_blocking(mesh_llm::model_catalog) +pub async fn mesh_model_catalog(app: AppHandle) -> CmdResult { + append_mesh_debug_log(&app, "mesh_model_catalog requested"); + let result = tokio::task::spawn_blocking(mesh_llm::model_catalog) .await - .map_err(|error| format!("mesh catalog task failed: {error}")) + .map_err(|error| format!("mesh catalog task failed: {error}")); + match &result { + Ok(catalog) => append_mesh_debug_log( + &app, + format!( + "mesh_model_catalog returned entries={} recommended={:?} vram_gb={}", + catalog.entries.len(), + catalog.recommended, + catalog.vram_gb + ), + ), + Err(error) => append_mesh_debug_log(&app, format!("mesh_model_catalog error: {error}")), + } + result } #[cfg(all(test, feature = "mesh-llm"))] diff --git a/desktop/src-tauri/src/commands/mesh_llm_tests.rs b/desktop/src-tauri/src/commands/mesh_llm_tests.rs index c4e1ae2e425..a1904971398 100644 --- a/desktop/src-tauri/src/commands/mesh_llm_tests.rs +++ b/desktop/src-tauri/src/commands/mesh_llm_tests.rs @@ -27,6 +27,129 @@ fn reported_target( target } +#[cfg(target_os = "windows")] +#[test] +fn windows_mesh_gpu_sdk_dll_dirs_discovers_versioned_rocm_and_cuda_dirs() { + let temp = tempfile::tempdir().unwrap(); + let program_files = temp.path().join("ProgramFiles"); + let hip_env = temp.path().join("HIP"); + let cuda_env = temp.path().join("CUDAEnv"); + let rocm64 = program_files.join("AMD").join("ROCm").join("6.4"); + let cuda12 = program_files + .join("NVIDIA GPU Computing Toolkit") + .join("CUDA") + .join("v12.4"); + for dir in [&hip_env, &cuda_env, &rocm64, &cuda12] { + std::fs::create_dir_all(dir.join("bin")).unwrap(); + } + + let dirs = windows_mesh_gpu_sdk_dll_dirs_from( + [ + ("HIP_PATH_64".into(), hip_env.into_os_string()), + ("CUDA_PATH_V12_4".into(), cuda_env.into_os_string()), + ], + Some(program_files.into_os_string()), + ); + + assert!(dirs + .iter() + .any(|dir| dir.ends_with("HIP/bin") || dir.ends_with("HIP\\bin"))); + assert!(dirs + .iter() + .any(|dir| dir.ends_with("ROCm/6.4/bin") || dir.ends_with("ROCm\\6.4\\bin"))); + assert!(dirs + .iter() + .any(|dir| dir.ends_with("CUDAEnv/bin") || dir.ends_with("CUDAEnv\\bin"))); + assert!(dirs + .iter() + .any(|dir| dir.ends_with("CUDA/v12.4/bin") || dir.ends_with("CUDA\\v12.4\\bin"))); +} + +#[cfg(target_os = "windows")] +#[test] +fn windows_mesh_dll_registration_order_prefers_runtime_libs_before_bundled_fallbacks() { + let temp = tempfile::tempdir().unwrap(); + let runtime_lib = temp + .path() + .join("native-runtimes") + .join(windows_mesh_native_runtime_version()) + .join("meshllm-native-runtime-windows-x86_64-vulkan") + .join("lib"); + let bundled = temp + .path() + .join("resources") + .join("mesh-llm") + .join("windows-x86_64"); + let gpu_sdk = temp.path().join("ROCm").join("6.4").join("bin"); + for dir in [&runtime_lib, &bundled, &gpu_sdk] { + std::fs::create_dir_all(dir).unwrap(); + } + + let dirs = windows_mesh_dll_registration_order( + vec![runtime_lib.clone()], + vec![bundled.clone()], + vec![gpu_sdk.clone()], + ); + + assert_eq!(dirs, vec![runtime_lib, bundled, gpu_sdk]); +} + +#[cfg(target_os = "windows")] +#[test] +fn windows_mesh_native_runtime_lib_dirs_are_registered_without_bundled_resources() { + let temp = tempfile::tempdir().unwrap(); + let native_root = temp.path().join("native-runtimes"); + let stale_runtime_lib = native_root + .join("0.74.0") + .join("meshllm-native-runtime-windows-x86_64-rocm") + .join("lib"); + let runtime_lib = native_root + .join(windows_mesh_native_runtime_version()) + .join("meshllm-native-runtime-windows-x86_64-vulkan") + .join("lib"); + std::fs::create_dir_all(&stale_runtime_lib).unwrap(); + std::fs::create_dir_all(&runtime_lib).unwrap(); + + let runtime_lib_dirs = windows_mesh_native_runtime_lib_dirs_from( + &native_root, + windows_mesh_native_runtime_version(), + ); + let registered_dirs = + windows_mesh_dll_registration_order(runtime_lib_dirs, Vec::new(), Vec::new()); + + assert_eq!(registered_dirs, vec![runtime_lib]); +} + +#[cfg(target_os = "windows")] +#[test] +fn windows_mesh_native_runtime_version_tracks_mesh_llm_dependency() { + assert_eq!( + windows_mesh_native_runtime_version(), + mesh_llm_host_runtime::VERSION + ); +} + +#[test] +fn mesh_runtime_load_error_retry_only_matches_windows_loader_failures() { + let load_library = anyhow::anyhow!( + "load native runtime meshllm-native-runtime-windows-x86_64-cuda12: LoadLibraryExW failed" + ); + let module_not_found = anyhow::anyhow!( + "failed to load native runtime library cublas64_12.dll: OS error 126: The specified module could not be found." + ); + let other = anyhow::anyhow!("model download failed"); + + assert!(mesh_runtime_load_error_needs_windows_dependency_retry( + &load_library + )); + assert!(mesh_runtime_load_error_needs_windows_dependency_retry( + &module_not_found + )); + assert!(!mesh_runtime_load_error_needs_windows_dependency_retry( + &other + )); +} + #[test] fn buzz_mesh_join_uses_the_same_live_member_from_every_other_node() { let targets = vec![ diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7aa954ce8e6..d21793151b6 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -74,7 +74,9 @@ use managed_agents::{ #[cfg(not(feature = "mesh-llm"))] use mesh_llm_stubs::*; #[cfg(all(feature = "mesh-llm", target_os = "macos"))] -use shutdown::{hard_exit_after_mesh_shutdown, relaunch_after_mesh_shutdown}; +use shutdown::hard_exit_after_mesh_shutdown; +#[cfg(feature = "mesh-llm")] +use shutdown::relaunch_after_mesh_shutdown; use shutdown::{is_restart_request, shut_down_app}; use std::sync::{atomic::AtomicBool, atomic::Ordering, Arc}; #[cfg(target_os = "macos")] @@ -87,9 +89,16 @@ use tray_menu::show_main_window; pub fn run() { // mesh-llm's async chains (model download, node start/join) overflow // tokio's default 2 MiB worker stacks — a stack-guard SIGABRT, not a - // panic. Upstream mesh-llm and mesh-console both run on 8 MiB worker - // stacks for this reason; give Tauri's command runtime the same headroom - // before anything else touches tauri::async_runtime. + // panic. Upstream mesh-llm and mesh-console both run on larger worker + // stacks for this reason; give Tauri's command runtime and mesh-llm's + // embedded runtime the same headroom before either starts. + #[cfg(feature = "mesh-llm")] + if std::env::var_os("MESH_TOKIO_STACK_SIZE").is_none() { + std::env::set_var( + "MESH_TOKIO_STACK_SIZE", + crate::mesh_llm::MESH_WORKER_STACK_SIZE.to_string(), + ); + } #[cfg(feature = "mesh-llm")] match tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -781,6 +790,9 @@ pub fn run() { put_agent_session_config, get_global_agent_config, set_global_agent_config, + mesh_debug_log, + mesh_debug_logging_enabled, + set_mesh_debug_logging_enabled, mesh_start_node, mesh_stop_node, mesh_node_status, @@ -974,7 +986,7 @@ pub fn run() { shut_down_app(app_handle, &run_shutdown_done); app_handle.state::().release(); - #[cfg(all(feature = "mesh-llm", target_os = "macos"))] + #[cfg(feature = "mesh-llm")] if restart_requested.load(Ordering::SeqCst) { relaunch_after_mesh_shutdown(app_handle); } diff --git a/desktop/src-tauri/src/mesh_llm/catalog.rs b/desktop/src-tauri/src/mesh_llm/catalog.rs index 1a11fcfcd13..1f677925d31 100644 --- a/desktop/src-tauri/src/mesh_llm/catalog.rs +++ b/desktop/src-tauri/src/mesh_llm/catalog.rs @@ -9,9 +9,11 @@ use serde::Serialize; use mesh_llm_client::models::catalog::{parse_size_gb, MODEL_CATALOG}; use mesh_llm_node::models::{default_huggingface_cache_dir, scan_installed_models}; -use mesh_llm_system::hardware; use mesh_llm_system::vram::{format_rated_capacity, rated_capacity_gb}; +#[cfg(not(target_os = "windows"))] +use mesh_llm_system::hardware; + /// Buzz-curated tier picks. These are the models we know survive the agent /// harness on shared compute — deliberately non-reasoning instruction models, /// so agents stay snappy instead of burning hidden reasoning tokens. @@ -58,10 +60,13 @@ pub enum ModelFit { Tight, Tradeoff, TooLarge, + Unknown, } fn fit_code(model_gb: f64, vram_gb: f64) -> ModelFit { - if model_gb <= vram_gb * 0.6 { + if vram_gb <= 0.0 { + ModelFit::Unknown + } else if model_gb <= vram_gb * 0.6 { ModelFit::Comfortable } else if model_gb <= vram_gb * 0.9 { ModelFit::Tight @@ -78,6 +83,7 @@ fn fit_rank(fit: ModelFit) -> u8 { ModelFit::Tight => 1, ModelFit::Tradeoff => 2, ModelFit::TooLarge => 3, + ModelFit::Unknown => 4, } } @@ -116,16 +122,99 @@ pub struct MeshModelCatalog { /// Draft (speculative-decoding) models are excluded — they are not something /// a person shares directly. pub fn model_catalog() -> MeshModelCatalog { - let survey = hardware::survey(); - let vram_gb = survey.vram_bytes as f64 / 1e9; + let hardware = catalog_hardware(); + let vram_gb = hardware.vram_bytes as f64 / 1e9; build_catalog( - survey.gpu_name.clone(), - survey.vram_bytes, + hardware.gpu_name, + hardware.vram_bytes, vram_gb, &installed_names(), ) } +struct CatalogHardware { + gpu_name: Option, + vram_bytes: u64, +} + +#[cfg(not(target_os = "windows"))] +fn catalog_hardware() -> CatalogHardware { + let survey = hardware::survey(); + CatalogHardware { + gpu_name: survey.gpu_name, + vram_bytes: survey.vram_bytes, + } +} + +#[cfg(target_os = "windows")] +fn catalog_hardware() -> CatalogHardware { + // MeshLLM's Windows survey adds a system-RAM offload budget to discrete + // GPU VRAM. That is useful for runtime placement, but misleading in the + // picker: a 16 GB card with 32 GB system RAM reads as ~32 GB and receives + // too-large recommendations. For the catalog, report and rank against + // dedicated GPU memory only. + select_catalog_adapter(&dxgi_adapters()) + .map(|adapter| CatalogHardware { + gpu_name: Some(adapter.name.clone()), + vram_bytes: adapter.dedicated_vram_bytes, + }) + .unwrap_or(CatalogHardware { + gpu_name: None, + vram_bytes: 0, + }) +} + +#[cfg(target_os = "windows")] +#[derive(Debug, Clone, PartialEq, Eq)] +struct DxgiAdapterInfo { + name: String, + dedicated_vram_bytes: u64, + software: bool, +} + +#[cfg(target_os = "windows")] +fn select_catalog_adapter(adapters: &[DxgiAdapterInfo]) -> Option<&DxgiAdapterInfo> { + adapters + .iter() + .filter(|adapter| !adapter.software && adapter.dedicated_vram_bytes > 0) + // Rank against a single adapter's memory. Summing multiple GPUs would + // recommend models that fit in no one adapter unless the runtime can + // explicitly shard layers across devices. + .max_by_key(|adapter| adapter.dedicated_vram_bytes) +} + +#[cfg(target_os = "windows")] +fn dxgi_adapters() -> Vec { + use windows::Win32::Graphics::Dxgi::{ + CreateDXGIFactory1, IDXGIFactory1, DXGI_ADAPTER_FLAG_SOFTWARE, + }; + + let Ok(factory) = (unsafe { CreateDXGIFactory1::() }) else { + return Vec::new(); + }; + + let mut adapters = Vec::new(); + let mut index = 0; + while let Ok(adapter) = unsafe { factory.EnumAdapters1(index) } { + index += 1; + let Ok(desc) = (unsafe { adapter.GetDesc1() }) else { + continue; + }; + adapters.push(DxgiAdapterInfo { + name: utf16_description(&desc.Description), + dedicated_vram_bytes: desc.DedicatedVideoMemory as u64, + software: (desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE.0 as u32) != 0, + }); + } + adapters +} + +#[cfg(target_os = "windows")] +fn utf16_description(value: &[u16]) -> String { + let len = value.iter().position(|ch| *ch == 0).unwrap_or(value.len()); + String::from_utf16_lossy(&value[..len]).trim().to_string() +} + fn installed_names() -> Vec<(String, String)> { let cache = default_huggingface_cache_dir(); scan_installed_models(cache) @@ -190,7 +279,8 @@ fn build_catalog( }); } - let recommended = Some(buzz_recommended_model(rated_capacity_gb(vram_bytes)).to_string()); + let recommended = + (vram_bytes > 0).then(|| buzz_recommended_model(rated_capacity_gb(vram_bytes)).to_string()); for entry in &mut entries { entry.recommended = recommended.as_deref() == Some(entry.name.as_str()); // Both curated tiers are always offered: the recommended one for this @@ -209,7 +299,11 @@ fn build_catalog( MeshModelCatalog { gpu_name, - vram_display: format_rated_capacity(vram_bytes), + vram_display: if vram_bytes > 0 { + format_rated_capacity(vram_bytes) + } else { + "Unknown".to_string() + }, vram_gb, recommended, entries, @@ -239,6 +333,7 @@ mod tests { assert_eq!(fit_code(10.0, 12.0), ModelFit::Tight); assert_eq!(fit_code(10.0, 10.0), ModelFit::Tradeoff); assert_eq!(fit_code(10.0, 8.0), ModelFit::TooLarge); + assert_eq!(fit_code(10.0, 0.0), ModelFit::Unknown); } #[test] @@ -285,6 +380,66 @@ mod tests { assert_eq!(tiny.recommended.as_deref(), Some(CURATED_SMALL)); } + #[cfg(target_os = "windows")] + #[test] + fn windows_catalog_uses_max_dedicated_adapter_vram() { + let adapters = vec![ + DxgiAdapterInfo { + name: "AMD Radeon RX 7600 XT".to_string(), + dedicated_vram_bytes: 16 * 1024 * 1024 * 1024, + software: false, + }, + DxgiAdapterInfo { + name: "NVIDIA GeForce RTX 4060".to_string(), + dedicated_vram_bytes: 8 * 1024 * 1024 * 1024, + software: false, + }, + ]; + let selected = select_catalog_adapter(&adapters).expect("adapter selected"); + assert_eq!(selected.name, "AMD Radeon RX 7600 XT"); + // The catalog ranks what fits on one adapter, not pooled multi-GPU VRAM. + assert_eq!(selected.dedicated_vram_bytes, 16 * 1024 * 1024 * 1024); + assert_eq!( + format_rated_capacity(selected.dedicated_vram_bytes), + "16 GB" + ); + } + + #[test] + fn unknown_vram_does_not_mark_entries_too_large() { + let catalog = build_catalog(None, 0, 0.0, &[]); + assert_eq!(catalog.vram_display, "Unknown"); + assert!(catalog.recommended.is_none()); + assert!(catalog + .entries + .iter() + .all(|entry| entry.fit == ModelFit::Unknown)); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_catalog_ignores_software_and_zero_vram_adapters() { + let adapters = vec![ + DxgiAdapterInfo { + name: "Microsoft Basic Render Driver".to_string(), + dedicated_vram_bytes: 32 * 1024 * 1024 * 1024, + software: true, + }, + DxgiAdapterInfo { + name: "DisplayLink".to_string(), + dedicated_vram_bytes: 0, + software: false, + }, + DxgiAdapterInfo { + name: "AMD Radeon RX 7600 XT".to_string(), + dedicated_vram_bytes: 16 * 1024 * 1024 * 1024, + software: false, + }, + ]; + let selected = select_catalog_adapter(&adapters).expect("hardware adapter selected"); + assert_eq!(selected.name, "AMD Radeon RX 7600 XT"); + } + #[test] fn curated_package_aliases_migrate_to_openai_model_ids() { assert_eq!( diff --git a/desktop/src-tauri/src/mesh_llm/mod.rs b/desktop/src-tauri/src/mesh_llm/mod.rs index e206c53886a..c4eae6721d5 100644 --- a/desktop/src-tauri/src/mesh_llm/mod.rs +++ b/desktop/src-tauri/src/mesh_llm/mod.rs @@ -305,9 +305,15 @@ async fn initialize_mesh_native_runtime() -> anyhow::Result<()> { /// stack-guard SIGABRT inside `download_model_ref_with_progress_details` /// when polled on Tauri's stock runtime. Upstream runs its own binary on /// 8 MiB worker stacks for exactly this reason (mesh-llm `main.rs`, -/// `DEFAULT_WORKER_STACK_SIZE`), as does mesh-console. `lib.rs` installs a -/// runtime with this stack size via `tauri::async_runtime::set` before the -/// app starts, so every command future gets the same headroom. +/// `DEFAULT_WORKER_STACK_SIZE`), as does mesh-console. Windows needs more +/// headroom in the packaged Tauri process: starting a serving node overflowed +/// at 8 MiB and still failed before command-body entry on some Windows builds. +/// `lib.rs` installs a runtime with this stack size before the app starts, and +/// the start command also runs the real start path on a dedicated thread with +/// this stack size. +#[cfg(target_os = "windows")] +pub const MESH_WORKER_STACK_SIZE: usize = 128 * 1024 * 1024; +#[cfg(not(target_os = "windows"))] pub const MESH_WORKER_STACK_SIZE: usize = 8 * 1024 * 1024; /// Pre-download the model (with byte progress through the output sink) diff --git a/desktop/src-tauri/src/mesh_llm_stubs.rs b/desktop/src-tauri/src/mesh_llm_stubs.rs index e8c13f48ea9..6298ff026af 100644 --- a/desktop/src-tauri/src/mesh_llm_stubs.rs +++ b/desktop/src-tauri/src/mesh_llm_stubs.rs @@ -4,6 +4,24 @@ use crate::app_state::AppState; type CmdResult = Result; +#[tauri::command] +pub async fn mesh_debug_log(_app: tauri::AppHandle, _message: String) -> CmdResult<()> { + Ok(()) +} + +#[tauri::command] +pub async fn mesh_debug_logging_enabled(_app: tauri::AppHandle) -> CmdResult { + Ok(false) +} + +#[tauri::command] +pub async fn set_mesh_debug_logging_enabled( + _app: tauri::AppHandle, + enabled: bool, +) -> CmdResult { + Ok(enabled) +} + #[tauri::command] pub async fn mesh_start_node( _app: tauri::AppHandle, diff --git a/desktop/src-tauri/src/shutdown.rs b/desktop/src-tauri/src/shutdown.rs index efd88f3cac5..b4c8993baa4 100644 --- a/desktop/src-tauri/src/shutdown.rs +++ b/desktop/src-tauri/src/shutdown.rs @@ -73,15 +73,18 @@ fn updated_macos_binary(current_binary: &std::path::Path) -> Option ! { +#[cfg(feature = "mesh-llm")] +pub(crate) fn relaunch_after_mesh_shutdown(app: &tauri::AppHandle) { use std::process::Command; tauri_plugin_single_instance::destroy(app); let env = app.env(); match tauri::process::current_binary(&env) { Ok(current_binary) => { + #[cfg(target_os = "macos")] let binary = updated_macos_binary(¤t_binary).unwrap_or(current_binary); + #[cfg(not(target_os = "macos"))] + let binary = current_binary; if let Err(error) = Command::new(binary) .args(env.args_os.iter().skip(1)) .spawn() @@ -91,7 +94,6 @@ pub(crate) fn relaunch_after_mesh_shutdown(app: &tauri::AppHandle) -> ! { } Err(error) => eprintln!("buzz-desktop: failed to locate app for relaunch: {error}"), } - hard_exit_after_mesh_shutdown(); } #[cfg(all(feature = "mesh-llm", target_os = "macos"))] diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 7258af371eb..01fb4dffbf2 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -60,6 +60,7 @@ "binaries/git-credential-nostr", "binaries/buzz" ], + "resources": ["resources/mesh-llm/windows-x86_64/*"], "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/desktop/src/features/mesh-compute/catalogDefault.test.mjs b/desktop/src/features/mesh-compute/catalogDefault.test.mjs new file mode 100644 index 00000000000..e730a2a2e21 --- /dev/null +++ b/desktop/src/features/mesh-compute/catalogDefault.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { defaultShareModelFromCatalog } from "./catalogDefault.ts"; + +function entry(name, overrides = {}) { + return { + name, + description: name, + size: "4B", + fit: "comfortable", + curated: false, + recommended: false, + installed: false, + ...overrides, + }; +} + +test("defaultShareModelFromCatalog prefers recommended usable entries", () => { + assert.equal( + defaultShareModelFromCatalog([ + entry("curated", { curated: true }), + entry("recommended", { recommended: true }), + ]), + "recommended", + ); +}); + +test("defaultShareModelFromCatalog falls back to curated entries", () => { + assert.equal( + defaultShareModelFromCatalog([ + entry("plain"), + entry("curated", { curated: true }), + ]), + "curated", + ); +}); + +test("defaultShareModelFromCatalog never picks too large entries", () => { + assert.equal( + defaultShareModelFromCatalog([ + entry("too-large-recommended", { + recommended: true, + fit: "too_large", + }), + entry("usable"), + ]), + "usable", + ); +}); + +test("defaultShareModelFromCatalog can pick unknown-fit entries", () => { + assert.equal( + defaultShareModelFromCatalog([entry("unknown", { fit: "unknown" })]), + "unknown", + ); +}); + +test("defaultShareModelFromCatalog returns null when nothing is usable", () => { + assert.equal( + defaultShareModelFromCatalog([entry("too-large", { fit: "too_large" })]), + null, + ); +}); diff --git a/desktop/src/features/mesh-compute/catalogDefault.ts b/desktop/src/features/mesh-compute/catalogDefault.ts new file mode 100644 index 00000000000..f3d98e78cda --- /dev/null +++ b/desktop/src/features/mesh-compute/catalogDefault.ts @@ -0,0 +1,18 @@ +import type { MeshCatalogEntry } from "@/shared/api/tauriMesh"; + +/** + * Pick the first usable model to prefill Share compute when the member has not + * chosen one yet. Prefer the curated/recommended path the UI shows above the + * fold, but never auto-select entries marked too large for this machine. + */ +export function defaultShareModelFromCatalog( + entries: MeshCatalogEntry[], +): string | null { + const usable = entries.filter((entry) => entry.fit !== "too_large"); + return ( + usable.find((entry) => entry.recommended)?.name ?? + usable.find((entry) => entry.curated)?.name ?? + usable[0]?.name ?? + null + ); +} diff --git a/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx b/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx index c27c5eef3ac..fc009af2c19 100644 --- a/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx +++ b/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx @@ -16,6 +16,9 @@ import { } from "@/features/agents/ui/agentConfigOptions"; import { + meshDebugLog, + meshDebugLoggingEnabled, + setMeshDebugLoggingEnabled, meshStartNode, meshStopNode, meshInstalledModels, @@ -29,6 +32,7 @@ import type { } from "@/shared/api/tauriMesh"; import { SettingsOptionGroup } from "@/features/settings/ui/SettingsOptionGroup"; import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; +import { defaultShareModelFromCatalog } from "../catalogDefault"; import { classifyModelRef } from "../classifyModelRef"; import { downloadPercent, @@ -99,6 +103,10 @@ export function MeshComputeSettingsCard() { ); const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false); const [advancedOpen, setAdvancedOpen] = React.useState(false); + const [diagnosticLoggingEnabled, setDiagnosticLoggingEnabled] = + React.useState(false); + const [diagnosticLoggingInFlight, setDiagnosticLoggingInFlight] = + React.useState(false); const [actionInFlight, setActionInFlight] = React.useState(false); const [pendingAction, setPendingAction] = React.useState< "start" | "stop" | null @@ -107,16 +115,67 @@ export function MeshComputeSettingsCard() { const { progress: downloadProgress, reset: resetDownloadProgress } = useMeshDownloadProgress(); + React.useEffect(() => { + meshDebugLog("MeshComputeSettingsCard mounted"); + return () => meshDebugLog("MeshComputeSettingsCard unmounted"); + }, []); + + React.useEffect(() => { + let cancelled = false; + meshDebugLoggingEnabled() + .then((enabled) => { + if (!cancelled) setDiagnosticLoggingEnabled(enabled); + }) + .catch(() => { + // Diagnostics state is non-critical; leave the switch off on failure. + }); + return () => { + cancelled = true; + }; + }, []); + + async function handleDiagnosticLoggingChange(enabled: boolean) { + setDiagnosticLoggingInFlight(true); + setDiagnosticLoggingEnabled(enabled); + try { + const saved = await setMeshDebugLoggingEnabled(enabled); + setDiagnosticLoggingEnabled(saved); + meshDebugLog(`diagnostic logging toggled enabled=${saved}`); + } catch (err) { + setDiagnosticLoggingEnabled(!enabled); + setActionError( + err instanceof Error + ? err.message + : "Could not update MeshLLM diagnostic logging.", + ); + } finally { + setDiagnosticLoggingInFlight(false); + } + } + + React.useEffect(() => { + meshDebugLog( + `MeshComputeSettingsCard state status=${status?.state ?? "null"} mode=${status?.mode ?? "null"} model=${status?.modelId ?? "null"} error=${error ?? "null"}`, + ); + }, [status?.state, status?.mode, status?.modelId, error]); + // Fetch installed models. Called on mount and whenever the running state // changes (a fresh start may have downloaded a new model). Stale-tolerant — // the picklist is a convenience, not load-bearing. const refreshInstalled = React.useCallback(() => { let cancelled = false; + meshDebugLog("refreshInstalled start"); (async () => { try { const list = await meshInstalledModels(); - if (!cancelled) setInstalledModels(list); - } catch { + if (!cancelled) { + meshDebugLog(`refreshInstalled success count=${list.length}`); + setInstalledModels(list); + } + } catch (err) { + meshDebugLog( + `refreshInstalled error ${err instanceof Error ? err.message : String(err)}`, + ); // Non-fatal — picklist just stays empty; user can still type a ref. } })(); @@ -135,17 +194,30 @@ export function MeshComputeSettingsCard() { // saved draft always wins. React.useEffect(() => { let cancelled = false; + meshDebugLog("catalog fetch start"); (async () => { try { const value = await meshModelCatalog(); if (cancelled) return; + meshDebugLog( + `catalog fetch success entries=${value.entries.length} recommended=${value.recommended ?? "null"}`, + ); setCatalog(value); setModelInput((current) => { - if (current.trim() !== "" || !value.recommended) return current; - writeDraft(MODEL_DRAFT_STORAGE_KEY, value.recommended); - return value.recommended; + if (current.trim() !== "") return current; + const fallback = + value.recommended ?? defaultShareModelFromCatalog(value.entries); + if (fallback) { + meshDebugLog(`catalog auto-select model=${fallback}`); + writeDraft(MODEL_DRAFT_STORAGE_KEY, fallback); + return fallback; + } + return current; }); - } catch { + } catch (err) { + meshDebugLog( + `catalog fetch error ${err instanceof Error ? err.message : String(err)}`, + ); // Non-fatal — picker just doesn't render. } })(); @@ -165,6 +237,9 @@ export function MeshComputeSettingsCard() { status.modelId && status.modelId !== modelInput ) { + meshDebugLog( + `mirror running status model into field model=${status.modelId}`, + ); setModelInput(status.modelId); writeDraft(MODEL_DRAFT_STORAGE_KEY, status.modelId); } @@ -185,15 +260,38 @@ export function MeshComputeSettingsCard() { // occupants remain locked until stopped/recovered. const controlsDisabled = actionInFlight || (slotOccupied && !isConsuming); const refClass = classifyModelRef(modelInput); - const canStart = refClass.kind !== "unknown" && !actionInFlight; + const canStart = + refClass.kind !== "unknown" && + !actionInFlight && + status?.state !== "starting"; + const visibleDownloadProgress = + actionInFlight && pendingAction === "start" ? downloadProgress : null; const showSharingControls = isSharing || pendingAction === "start"; + React.useEffect(() => { + meshDebugLog( + `derived sharing=${isSharing} consuming=${isConsuming} slotOccupied=${slotOccupied} controlsDisabled=${controlsDisabled} canStart=${canStart} refKind=${refClass.kind} modelInput=${modelInput.trim()}`, + ); + }, [ + isSharing, + isConsuming, + slotOccupied, + controlsDisabled, + canStart, + refClass.kind, + modelInput, + ]); + async function handleToggle(next: boolean) { // Never let the Share switch tear down a consume session. The switch is // already disabled while consuming, but status can be stale between polls, // so refuse a stop that isn't stopping OUR serve node as a belt-and-braces // guard (the backend enforces this authoritatively too). + meshDebugLog( + `handleToggle next=${next} isSharing=${isSharing} isConsuming=${isConsuming} slotOccupied=${slotOccupied} canStart=${canStart} model=${modelInput.trim()} maxVram=${maxVramGb.trim()}`, + ); if (!next && !isSharing) { + meshDebugLog("handleToggle ignored stop because not sharing"); return; } setActionError(null); @@ -203,21 +301,29 @@ export function MeshComputeSettingsCard() { if (next) { const maxVram = maxVramGb.trim() === "" ? undefined : Number.parseFloat(maxVramGb); - await meshStartNode({ - mode: "serve", + const request = { + mode: "serve" as const, modelId: modelInput.trim() || undefined, maxVramGb: typeof maxVram === "number" && !Number.isNaN(maxVram) ? maxVram : undefined, - }); + }; + meshDebugLog(`handleToggle start invoking ${JSON.stringify(request)}`); + await meshStartNode(request); + meshDebugLog("handleToggle start completed"); } else { + meshDebugLog("handleToggle stop invoking"); await meshStopNode(); + meshDebugLog("handleToggle stop completed"); } refresh(); } catch (err) { - setActionError(err instanceof Error ? err.message : String(err)); + const message = err instanceof Error ? err.message : String(err); + meshDebugLog(`handleToggle error ${message}`); + setActionError(message); } finally { + meshDebugLog("handleToggle finally"); setActionInFlight(false); setPendingAction(null); resetDownloadProgress(); @@ -241,8 +347,8 @@ export function MeshComputeSettingsCard() { {actionError}

) : null} - {downloadProgress ? ( - + {visibleDownloadProgress ? ( + ) : null} @@ -290,6 +396,7 @@ export function MeshComputeSettingsCard() { model={modelInput} onCustomModelEditingChange={setIsCustomModelEditing} onModelChange={(next) => { + meshDebugLog(`model changed value=${next}`); setModelInput(next); writeDraft(MODEL_DRAFT_STORAGE_KEY, next); }} @@ -300,7 +407,13 @@ export function MeshComputeSettingsCard() { aria-expanded={advancedOpen} className="inline-flex h-9 items-center gap-1.5 text-sm font-medium text-foreground transition-colors hover:text-foreground/80 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring" data-testid="mesh-share-compute-advanced-toggle" - onClick={() => setAdvancedOpen((current) => !current)} + onClick={() => { + setAdvancedOpen((current) => { + const next = !current; + meshDebugLog(`advanced toggled open=${next}`); + return next; + }); + }} type="button" > Advanced @@ -312,37 +425,62 @@ export function MeshComputeSettingsCard() { /> {advancedOpen ? ( -
- - { - const next = e.target.value; - setMaxVramGb(next); - writeDraft(MAX_VRAM_DRAFT_STORAGE_KEY, next); - }} - placeholder="No limit" - usePersonaInputStyle - value={maxVramGb} - /> - {status?.consoleUrl ? ( -

- Debug console:{" "} - +

+
+ -

- ) : null} + Enable MeshLLM diagnostic logging + +

+ Writes troubleshooting logs to your temp folder and Buzz app + data. Turn this off when finished. +

+
+ +
+ +
+ + { + const next = e.target.value; + meshDebugLog(`max vram input changed value=${next}`); + setMaxVramGb(next); + writeDraft(MAX_VRAM_DRAFT_STORAGE_KEY, next); + }} + placeholder="No limit" + usePersonaInputStyle + value={maxVramGb} + /> + {status?.consoleUrl ? ( +

+ Debug console:{" "} + + {status.consoleUrl} + +

+ ) : null} +
) : null} @@ -458,6 +596,7 @@ const FIT_LABEL: Record = { tight: "Tight fit", tradeoff: "Trade-off", too_large: "Too large", + unknown: "Unknown fit", }; const FIT_CLASS: Record = { @@ -465,6 +604,7 @@ const FIT_CLASS: Record = { tight: "text-amber-600 dark:text-amber-400", tradeoff: "text-orange-600 dark:text-orange-400", too_large: "text-destructive", + unknown: "text-muted-foreground", }; /** diff --git a/desktop/src/shared/api/tauriMesh.ts b/desktop/src/shared/api/tauriMesh.ts index 8a0153123f4..346a11f5115 100644 --- a/desktop/src/shared/api/tauriMesh.ts +++ b/desktop/src/shared/api/tauriMesh.ts @@ -38,18 +38,81 @@ export type MeshNodeStatus = { deviceName?: string | null; }; +let meshDiagnosticLoggingEnabled = false; + +export function meshDebugLog(message: string): void { + if (!meshDiagnosticLoggingEnabled) return; + void invokeTauri("mesh_debug_log", { message }).catch(() => { + // Diagnostic logging must never affect the UI path. + }); +} + +export async function meshDebugLoggingEnabled(): Promise { + const enabled = await invokeTauri("mesh_debug_logging_enabled"); + meshDiagnosticLoggingEnabled = enabled; + return enabled; +} + +export async function setMeshDebugLoggingEnabled( + enabled: boolean, +): Promise { + const saved = await invokeTauri("set_mesh_debug_logging_enabled", { + enabled, + }); + meshDiagnosticLoggingEnabled = saved; + return saved; +} + export async function meshStartNode( request: StartMeshNodeRequest, ): Promise { - return await invokeTauri("mesh_start_node", { request }); + meshDebugLog(`api mesh_start_node invoke ${JSON.stringify(request)}`); + try { + const status = await invokeTauri("mesh_start_node", { + request, + }); + meshDebugLog( + `api mesh_start_node ok state=${status.state} mode=${status.mode} model=${status.modelId}`, + ); + return status; + } catch (err) { + meshDebugLog( + `api mesh_start_node error ${err instanceof Error ? err.message : String(err)}`, + ); + throw err; + } } export async function meshStopNode(): Promise { - return await invokeTauri("mesh_stop_node"); + meshDebugLog("api mesh_stop_node invoke"); + try { + const status = await invokeTauri("mesh_stop_node"); + meshDebugLog( + `api mesh_stop_node ok state=${status.state} mode=${status.mode} model=${status.modelId}`, + ); + return status; + } catch (err) { + meshDebugLog( + `api mesh_stop_node error ${err instanceof Error ? err.message : String(err)}`, + ); + throw err; + } } export async function meshNodeStatus(): Promise { - return await invokeTauri("mesh_node_status"); + meshDebugLog("api mesh_node_status invoke"); + try { + const status = await invokeTauri("mesh_node_status"); + meshDebugLog( + `api mesh_node_status ok state=${status.state} mode=${status.mode} model=${status.modelId}`, + ); + return status; + } catch (err) { + meshDebugLog( + `api mesh_node_status error ${err instanceof Error ? err.message : String(err)}`, + ); + throw err; + } } /** @@ -70,14 +133,43 @@ export type MeshServingUsage = { }; export async function meshServingUsage(): Promise { - return await invokeTauri("mesh_serving_usage"); + meshDebugLog("api mesh_serving_usage invoke"); + try { + const usage = await invokeTauri("mesh_serving_usage"); + meshDebugLog( + `api mesh_serving_usage ok inflight=${usage.inflight} requests=${usage.requestsServed} remote=${usage.remoteAttempts}`, + ); + return usage; + } catch (err) { + meshDebugLog( + `api mesh_serving_usage error ${err instanceof Error ? err.message : String(err)}`, + ); + throw err; + } } export async function meshInstalledModels(): Promise { - return await invokeTauri("mesh_installed_models"); + meshDebugLog("api mesh_installed_models invoke"); + try { + const models = await invokeTauri( + "mesh_installed_models", + ); + meshDebugLog(`api mesh_installed_models ok count=${models.length}`); + return models; + } catch (err) { + meshDebugLog( + `api mesh_installed_models error ${err instanceof Error ? err.message : String(err)}`, + ); + throw err; + } } -export type MeshModelFit = "comfortable" | "tight" | "tradeoff" | "too_large"; +export type MeshModelFit = + | "comfortable" + | "tight" + | "tradeoff" + | "too_large" + | "unknown"; export type MeshCatalogEntry = { /** Catalog name — valid as-is in the model field. */ @@ -110,5 +202,17 @@ export type MeshModelCatalog = { * Works without a running mesh node (hardware survey + HF cache scan). */ export async function meshModelCatalog(): Promise { - return await invokeTauri("mesh_model_catalog"); + meshDebugLog("api mesh_model_catalog invoke"); + try { + const catalog = await invokeTauri("mesh_model_catalog"); + meshDebugLog( + `api mesh_model_catalog ok entries=${catalog.entries.length} recommended=${catalog.recommended} vram=${catalog.vramGb}`, + ); + return catalog; + } catch (err) { + meshDebugLog( + `api mesh_model_catalog error ${err instanceof Error ? err.message : String(err)}`, + ); + throw err; + } } diff --git a/scripts/bundle-windows-mesh-runtime-deps.ps1 b/scripts/bundle-windows-mesh-runtime-deps.ps1 new file mode 100644 index 00000000000..1568ccf70ae --- /dev/null +++ b/scripts/bundle-windows-mesh-runtime-deps.ps1 @@ -0,0 +1,61 @@ +$ErrorActionPreference = 'Stop' + +$repoRoot = Split-Path -Parent $PSScriptRoot +$dest = Join-Path $repoRoot 'desktop\src-tauri\resources\mesh-llm\windows-x86_64' +New-Item -ItemType Directory -Force -Path $dest | Out-Null + +$required = @( + 'libgcc_s_seh-1.dll', + 'libstdc++-6.dll', + 'libgomp-1.dll', + 'libwinpthread-1.dll' +) + +$candidateDirs = @( + 'C:\msys64\mingw64\bin', + 'C:\msys64\ucrt64\bin', + 'C:\ProgramData\mingw64\mingw64\bin', + 'C:\Program Files\Git\mingw64\bin' +) + +function Find-MeshRuntimeDependency($name) { + foreach ($dir in $candidateDirs) { + $path = Join-Path $dir $name + if (Test-Path -LiteralPath $path -PathType Leaf) { + return $path + } + } + return $null +} + +function Get-MissingMeshRuntimeDependencies { + $required | Where-Object { -not (Find-MeshRuntimeDependency $_) } +} + +$missing = @(Get-MissingMeshRuntimeDependencies) +if ($missing.Count -gt 0) { + Write-Host "Missing Windows MeshLLM runtime dependencies before bootstrap: $($missing -join ', ')" + $pacman = 'C:\msys64\usr\bin\pacman.exe' + if (Test-Path -LiteralPath $pacman -PathType Leaf) { + & $pacman -Sy --noconfirm --needed mingw-w64-x86_64-gcc + } +} + +$missing = @(Get-MissingMeshRuntimeDependencies) +if ($missing.Count -gt 0) { + $choco = Get-Command choco -ErrorAction SilentlyContinue + if ($choco) { + & $choco.Source install mingw -y --no-progress | Out-Null + } +} + +$missing = @(Get-MissingMeshRuntimeDependencies) +if ($missing.Count -gt 0) { + throw "Missing Windows MeshLLM runtime dependencies after bootstrap: $($missing -join ', ')" +} + +foreach ($name in $required) { + $source = Find-MeshRuntimeDependency $name + Copy-Item -LiteralPath $source -Destination (Join-Path $dest $name) -Force + Write-Host "Bundled $name from $source" +}