diff --git a/crates/buzz-relay/examples/mesh_agent_e2e.rs b/crates/buzz-relay/examples/mesh_agent_e2e.rs index db0fc6090b..eced61a8ad 100644 --- a/crates/buzz-relay/examples/mesh_agent_e2e.rs +++ b/crates/buzz-relay/examples/mesh_agent_e2e.rs @@ -5,10 +5,9 @@ //! //! Permutations: //! P1 explicit-model chat — agent pinned to the served model id replies. -//! P2 auto-model chat — agent sends `model: "auto"`; mesh router picks. -//! P3 context-fit regression — an oversized output budget (150k tokens) -//! must FAIL with the router's context error (proves the router's fit -//! gate — the failure mode the 1024 preset cap protects against). +//! P2 virtual-mesh chat — one served model exercises single-model fallback. +//! P3 generous output ceiling succeeds (Mesh #1350); oversized input still +//! fails context admission via the real HTTP router, before inference. //! P4 agentic tool use — agent + buzz-dev-mcp writes a file on disk. //! //! The serve node is the same `mesh_llm_sdk::serve` path Share-compute uses @@ -136,13 +135,11 @@ async fn run() -> anyhow::Result<()> { ), } - // P3: regression — an output budget no served model's context can hold - // must be rejected by the router with the context-fit error (the failure - // mode that broke relay-mesh agents when buzz-agent's default 32768 - // budget met a 32k-context model). 150k output: passes buzz-agent's own - // config validation (must stay under its 200k max_context_tokens) but - // with the router's +25% margin overflows even 128k-context models like - // GLM-4.7-Flash. + // Mesh v0.76.0-rc9 (9f192c9), routing_rank.rs:43-58 reserves + // min(completion ceiling, 512) decode headroom, NOT the entire ceiling + // (#1350). buzz-agent llm.rs sends max_completion_tokens on the chat API. + // Keep the 150k ceiling as a positive regression, plus a negative input + // fit probe below. Do not accept generic service failures as context errors. let r = agent_chat( &base, "mesh", @@ -152,18 +149,13 @@ async fn run() -> anyhow::Result<()> { ) .await; match r { - Ok(text) => record( - "P3 oversized-budget must fail", - false, - format!("unexpectedly succeeded: {text}"), - ), - Err(e) => { - let msg = e.to_string(); - let is_context_503 = msg.contains("503") - || msg.contains("service_unavailable") - || msg.contains("context-compatible"); - record("P3 oversized-budget must fail", is_context_503, msg); - } + Ok(text) => record("P3a generous output ceiling", text.trim() == "PONG", text), + Err(e) => record("P3a generous output ceiling", false, e.to_string()), + } + let r = oversized_input_probe(&http, &base, &served_id).await; + match r { + Ok(detail) => record("P3b oversized input rejected", true, detail), + Err(e) => record("P3b oversized input rejected", false, e.to_string()), } // P4: agentic tool use via buzz-dev-mcp — write a real file inside the @@ -171,13 +163,13 @@ async fn run() -> anyhow::Result<()> { // nonexistent absolute paths outside that root. let marker_name = format!("mesh-e2e-{}.txt", std::process::id()); let prompt = format!( - "Use your developer tools to create {marker_name} in the current working directory containing exactly the text BUZZ_OK (no quotes, no newline commentary). Then confirm." + "Call dev__shell exactly once with command `{}` and no workdir override. This is the only authorized operation. Then confirm in text; do not run any other tools.", marker_command(&marker_name) ); let mcp = vec![("dev".to_string(), repo_bin("buzz-dev-mcp")?)]; let (r, marker) = agent_chat_with_marker(&base, "mesh", None, &prompt, &mcp, &marker_name).await; let file_ok = std::fs::read_to_string(&marker) - .map(|c| c.contains("BUZZ_OK")) + .map(|c| c == "BUZZ_OK") .unwrap_or(false); match r { Ok(text) => record( @@ -189,7 +181,7 @@ async fn run() -> anyhow::Result<()> { format!("no file at {}; agent said: {text}", marker.display()) }, ), - Err(e) => record("P4 agentic tool use", file_ok, format!("agent error: {e}")), + Err(e) => record("P4 agentic tool use", false, format!("agent error: {e}")), } let _ = std::fs::remove_file(&marker); @@ -237,7 +229,8 @@ async fn agent_chat( mcp_servers: &[(String, String)], ) -> anyhow::Result { let (result, _) = - agent_chat_in_isolated_home(base, model, max_output_tokens, prompt, mcp_servers).await; + agent_chat_in_isolated_home(base, model, max_output_tokens, prompt, mcp_servers, None) + .await; result } @@ -249,8 +242,15 @@ async fn agent_chat_with_marker( mcp_servers: &[(String, String)], marker_name: &str, ) -> (anyhow::Result, std::path::PathBuf) { - let (result, home) = - agent_chat_in_isolated_home(base, model, max_output_tokens, prompt, mcp_servers).await; + let (result, home) = agent_chat_in_isolated_home( + base, + model, + max_output_tokens, + prompt, + mcp_servers, + Some(marker_name), + ) + .await; (result, home.join(marker_name)) } @@ -260,19 +260,25 @@ async fn agent_chat_in_isolated_home( max_output_tokens: Option<&str>, prompt: &str, mcp_servers: &[(String, String)], + marker_name: Option<&str>, ) -> (anyhow::Result, std::path::PathBuf) { let agent = match repo_bin("buzz-agent") { Ok(agent) => agent, Err(error) => return (Err(error), std::path::PathBuf::new()), }; // Isolated HOME: no skills, no AGENTS.md chain, no keychain, tiny prompt. - let home = std::env::temp_dir().join(format!("mesh-e2e-home-{}", std::process::id())); - if let Err(error) = std::fs::create_dir_all(&home) { - return (Err(error.into()), home); - } + // Fresh per leg: stale markers and prior agent state can never pass P4. + // Retain the directory for diagnostics (also when P4 fails). + let home = match tempfile::Builder::new().prefix("mesh-e2e-home-").tempdir() { + Ok(dir) => dir.keep(), + Err(error) => return (Err(error.into()), std::path::PathBuf::new()), + }; + eprintln!("[e2e] isolated home: {}", home.display()); let mut command = Command::new(&agent); command + .current_dir(&home) + .kill_on_drop(true) .env_clear() .env("PATH", std::env::var("PATH").unwrap_or_default()) .env("HOME", &home) @@ -291,10 +297,8 @@ async fn agent_chat_in_isolated_home( // Pinning a value here would test a config the product does not ship. .stdin(Stdio::piped()) .stdout(Stdio::piped()) - .stderr(Stdio::null()); - // P3 deliberately overrides the production default to exercise the - // router's context-fit rejection. Normal and tool turns leave it unset, - // matching the desktop provider path. + .stderr(Stdio::inherit()); + // P3a exercises a generous ceiling, not an admission reservation. if let Some(value) = max_output_tokens { command.env("BUZZ_AGENT_MAX_OUTPUT_TOKENS", value); } @@ -303,7 +307,7 @@ async fn agent_chat_in_isolated_home( Err(error) => return (Err(error.into()), home), }; - let result = drive_acp(&mut child, prompt, mcp_servers, &home).await; + let result = drive_acp(&mut child, prompt, mcp_servers, &home, marker_name).await; let _ = child.kill().await; (result, home) } @@ -313,6 +317,7 @@ async fn drive_acp( prompt: &str, mcp_servers: &[(String, String)], cwd: &std::path::Path, + marker_name: Option<&str>, ) -> anyhow::Result { let mut stdin = child .stdin @@ -341,7 +346,68 @@ async fn drive_acp( .as_bytes(), ) .await?; - stdin + let mut session_id: Option = None; + let mut agent_text = String::new(); + let mut marker_approved = false; + let deadline = tokio::time::Instant::now() + Duration::from_secs(600); + + loop { + let line = tokio::time::timeout_at(deadline, lines.next_line()) + .await + .map_err(|_| anyhow::anyhow!("agent timed out; text so far: {agent_text}"))?? + .ok_or_else(|| anyhow::anyhow!("agent closed stdout; text so far: {agent_text}"))?; + let Ok(msg) = serde_json::from_str::(&line) else { + continue; + }; + // Requests have their own id namespace; handle before response ids. + if msg["method"] == "session/request_permission" { + let response = permission_response( + &msg, + session_id.as_deref(), + marker_name, + &mut marker_approved, + ); + eprintln!( + "[e2e] permission: {} -> {}", + msg["params"]["toolCall"], response["result"] + ); + stdin.write_all(send(response).as_bytes()).await?; + continue; + } + if msg.get("method").is_some() && msg.get("id").is_some() { + stdin + .write_all( + send(serde_json::json!({ + "jsonrpc": "2.0", "id": msg["id"], + "error": {"code": -32601, "message": "Unsupported harness method"} + })) + .as_bytes(), + ) + .await?; + continue; + } + // Collect any streamed agent text from session/update notifications. + if msg.get("method").and_then(|m| m.as_str()) == Some("session/update") { + if msg["params"]["sessionId"].as_str() == session_id.as_deref() { + let update = &msg["params"]["update"]; + if matches!( + update["sessionUpdate"].as_str(), + Some("tool_call" | "tool_call_update") + ) { + eprintln!("[e2e] tool update: {update}"); + } + collect_text(update, &mut agent_text); + } + continue; + } + match msg.get("id").and_then(|i| i.as_i64()) { + Some(1) => { + anyhow::ensure!(msg.get("error").is_none(), "initialize failed: {msg}"); + anyhow::ensure!( + msg["result"]["protocolVersion"] == 1, + "expected ACP v1: {msg}" + ); + stdin .write_all( send(serde_json::json!({ "jsonrpc": "2.0", "id": 2, "method": "session/new", @@ -354,25 +420,7 @@ async fn drive_acp( .as_bytes(), ) .await?; - - let mut session_id: Option = None; - let mut agent_text = String::new(); - let deadline = tokio::time::Instant::now() + Duration::from_secs(600); - - loop { - let line = tokio::time::timeout_at(deadline, lines.next_line()) - .await - .map_err(|_| anyhow::anyhow!("agent timed out; text so far: {agent_text}"))?? - .ok_or_else(|| anyhow::anyhow!("agent closed stdout; text so far: {agent_text}"))?; - let Ok(msg) = serde_json::from_str::(&line) else { - continue; - }; - // Collect any streamed agent text from session/update notifications. - if msg.get("method").and_then(|m| m.as_str()) == Some("session/update") { - collect_text(&msg["params"]["update"], &mut agent_text); - continue; - } - match msg.get("id").and_then(|i| i.as_i64()) { + } Some(2) => { if let Some(err) = msg.get("error") { anyhow::bail!("session/new failed: {err}"); @@ -400,6 +448,14 @@ async fn drive_acp( if let Some(err) = msg.get("error") { anyhow::bail!("session/prompt failed: {err}"); } + anyhow::ensure!( + msg["result"]["stopReason"] == "end_turn", + "unexpected prompt stop: {msg}; text: {agent_text}" + ); + anyhow::ensure!( + marker_name.is_none() || marker_approved, + "marker permission was never approved; text: {agent_text}" + ); return Ok(agent_text.trim().to_string()); } _ => {} @@ -407,25 +463,116 @@ async fn drive_acp( } } -/// Recursively harvest "text" string fields out of a session/update payload. +/// Only user-visible assistant chunks count, never reasoning or tool content. fn collect_text(value: &serde_json::Value, out: &mut String) { - match value { - serde_json::Value::Object(map) => { - for (k, v) in map { - if k == "text" { - if let Some(s) = v.as_str() { - out.push_str(s); - } - } else { - collect_text(v, out); - } - } + if value["sessionUpdate"] == "agent_message_chunk" && value["content"]["type"] == "text" { + if let Some(text) = value["content"]["text"].as_str() { + out.push_str(text); } - serde_json::Value::Array(items) => { - for item in items { - collect_text(item, out); - } - } - _ => {} } } + +fn marker_command(name: &str) -> String { + format!("printf %s BUZZ_OK > {name}") +} + +/// Test-client-only grant: one exact shell invocation, in a fresh cwd. +/// No command parsing, prefix matches, persistent grants, or alternate tools. +fn permission_response( + request: &serde_json::Value, + session: Option<&str>, + marker: Option<&str>, + approved: &mut bool, +) -> serde_json::Value { + let p = &request["params"]; + let tool = &p["toolCall"]; // We negotiate v1 only; fail closed on other shapes. + let input = &tool["rawInput"]; + let safe_marker = marker.filter(|name| { + !name.is_empty() + && name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.') + }); + let exact_input = safe_marker.is_some_and(|name| { + input.as_object().is_some_and(|o| { + o.keys() + .all(|k| k == "command" || k == "timeout_ms" || k == "workdir") + // MCP treats absent/null workdir as its default cwd; no overrides. + && (input.get("workdir").is_none() || input["workdir"].is_null()) + && input["command"] == marker_command(name) + && (input.get("timeout_ms").is_none() + || input["timeout_ms"] + .as_u64() + .is_some_and(|n| n > 0 && n <= 120_000)) + }) + }); + let allow = !*approved + && session.is_some() + && p["sessionId"].as_str() == session + && tool["title"] == "dev__shell" + && exact_input; + let option = p["options"] + .as_array() + .and_then(|options| options.iter().find(|o| o["kind"] == "allow_once")) + .and_then(|o| o["optionId"].as_str()); + let outcome = if let Some(id) = option.filter(|_| allow) { + *approved = true; + serde_json::json!({"outcome": "selected", "optionId": id}) + } else { + serde_json::json!({"outcome": "cancelled"}) + }; + serde_json::json!({"jsonrpc": "2.0", "id": request["id"], "result": {"outcome": outcome}}) +} + +async fn oversized_input_probe( + http: &reqwest::Client, + base: &str, + model: &str, +) -> anyhow::Result { + // Use advertised runtime capacity, not native model capacity. Explicit + // model routing avoids conflating MoA selection failure with context fit. + let models: serde_json::Value = http + .get(format!("{base}/models")) + .send() + .await? + .error_for_status()? + .json() + .await?; + let entry = models["data"] + .as_array() + .and_then(|a| a.iter().find(|m| m["id"] == model)) + .ok_or_else(|| anyhow::anyhow!("served model absent: {models}"))?; + let metadata = &entry["metadata"]; + let context = metadata["max_context_length"] + .as_u64() + .or_else(|| metadata["context_length"].as_u64()) + .ok_or_else(|| anyhow::anyhow!("no advertised runtime context: {entry}"))?; + // Bound allocation/request size; fail rather than guess for huge models. + anyhow::ensure!( + context > 0 && context <= 262_144, + "unsupported smoke context: {context}" + ); + // rc9 estimates serialized input bytes / 4, retains ALL prompt tokens. + let body = serde_json::json!({"model": model, "stream": false, + "max_completion_tokens": 1, + "messages": [{"role": "user", "content": "x".repeat((context as usize + 1024) * 4)}]}); + let response = http + .post(format!("{base}/chat/completions")) + .timeout(Duration::from_secs(30)) + .json(&body) + .send() + .await?; + let status = response.status(); + let text = response.text().await?; + anyhow::ensure!( + status == reqwest::StatusCode::SERVICE_UNAVAILABLE + && text.contains("no context-compatible target"), + "expected context-fit 503 for runtime context {context}, got {status}: {}", + text.chars().take(2048).collect::() + ); + Ok(format!("runtime context {context}; {status}: {text}")) +} + +#[cfg(test)] +#[path = "mesh_agent_e2e/tests.rs"] +mod tests; diff --git a/crates/buzz-relay/examples/mesh_agent_e2e/tests.rs b/crates/buzz-relay/examples/mesh_agent_e2e/tests.rs new file mode 100644 index 0000000000..d7413f1e9f --- /dev/null +++ b/crates/buzz-relay/examples/mesh_agent_e2e/tests.rs @@ -0,0 +1,185 @@ +use super::*; +use serde_json::{json, Value}; + +fn request() -> Value { + json!({"jsonrpc":"2.0", "id":3, "method":"session/request_permission", + "params":{"sessionId":"s", "toolCall":{"title":"dev__shell", + "rawInput":{"command":marker_command("marker.txt")}}, + "options":[{"kind":"allow_always","optionId":"persistent"}, + {"kind":"allow_once","optionId":"one-shot"}]}}) +} + +#[test] +fn exact_marker_grants_offered_once_option_and_preserves_id() { + let mut approved = false; + let response = permission_response(&request(), Some("s"), Some("marker.txt"), &mut approved); + assert!(approved); + assert_eq!(response["id"], 3); + assert_eq!( + response["result"]["outcome"], + json!({"outcome":"selected","optionId":"one-shot"}) + ); + assert_eq!( + permission_response(&request(), Some("s"), Some("marker.txt"), &mut approved)["result"] + ["outcome"]["outcome"], + "cancelled" + ); +} + +#[test] +fn hardware_payload_allows_null_workdir_but_rejects_non_null_values() { + let mut r = request(); + r["params"]["toolCall"]["rawInput"] = json!({ + "command": "printf %s BUZZ_OK > marker.txt", + "timeout_ms": 120000, + "workdir": null + }); + let mut approved = false; + let response = permission_response(&r, Some("s"), Some("marker.txt"), &mut approved); + assert!(approved); + assert_eq!(response["id"], 3); + assert_eq!( + response["result"]["outcome"], + json!({"outcome":"selected","optionId":"one-shot"}) + ); + assert_eq!( + permission_response(&r, Some("s"), Some("marker.txt"), &mut approved)["result"]["outcome"] + ["outcome"], + "cancelled" + ); + for workdir in [ + json!(""), + json!("/tmp"), + json!("."), + json!(false), + json!(0), + json!([]), + json!({}), + ] { + r["params"]["toolCall"]["rawInput"]["workdir"] = workdir; + let mut approved = false; + let response = permission_response(&r, Some("s"), Some("marker.txt"), &mut approved); + assert_eq!(response["result"]["outcome"]["outcome"], "cancelled", "{r}"); + assert!(!approved); + } +} + +#[test] +fn permission_fails_closed_on_scope_command_options_and_protocol() { + let mut mutations = Vec::new(); + for command in [ + "printf %s BUZZ_OK > other.txt", + "printf %s BUZZ_OK > marker.txt; pwd", + "printf %s BUZZ_OK > ../marker.txt", + "echo BUZZ_OK > marker.txt", + ] { + let mut r = request(); + r["params"]["toolCall"]["rawInput"]["command"] = json!(command); + mutations.push(r); + } + for (pointer, value) in [ + ("/params/sessionId", json!("other")), + ("/params/toolCall/title", json!("other__shell")), + ( + "/params/toolCall/rawInput", + json!({"command":marker_command("marker.txt"),"workdir":"/tmp"}), + ), + ( + "/params/toolCall/rawInput", + json!({"command":marker_command("marker.txt"),"timeout_ms":999999}), + ), + ( + "/params/options", + json!([{"kind":"allow_always","optionId":"always"}]), + ), + ("/params/toolCall", Value::Null), + ] { + let mut r = request(); + *r.pointer_mut(pointer).unwrap() = value; + mutations.push(r); + } + for r in mutations { + let mut approved = false; + let response = permission_response(&r, Some("s"), Some("marker.txt"), &mut approved); + assert_eq!(response["result"]["outcome"]["outcome"], "cancelled", "{r}"); + assert!(!approved); + } + for (session, marker) in [ + (None, Some("marker.txt")), + (Some("s"), None), + (Some("s"), Some("../marker.txt")), + ] { + assert_eq!( + permission_response(&request(), session, marker, &mut false)["result"]["outcome"] + ["outcome"], + "cancelled" + ); + } +} + +#[test] +fn collector_excludes_thoughts_tool_results_and_nontext_content() { + let mut text = String::new(); + for update in [ + "agent_thought_chunk", + "tool_call", + "tool_call_update", + "user_message_chunk", + ] { + collect_text( + &json!({"sessionUpdate":update,"content":{"type":"text","text":"PONG"}}), + &mut text, + ); + } + collect_text( + &json!({"sessionUpdate":"agent_message_chunk","content":{"type":"image","text":"PONG"}}), + &mut text, + ); + assert!(text.is_empty()); + for chunk in ["PO", "NG"] { + collect_text( + &json!({"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":chunk}}), + &mut text, + ); + } + assert_eq!(text, "PONG"); +} + +/// Real stdio framing through drive_acp, no model, MCP or native runtime. +/// The permission request intentionally collides with our prompt response id. +#[tokio::test] +async fn acp_permission_request_is_not_mistaken_for_prompt_response() { + let script = r#" +import json, sys +def read(): return json.loads(sys.stdin.readline()) +def send(v): print(json.dumps(v), flush=True) +assert read()['method'] == 'initialize' +send({'id':1,'result':{'protocolVersion':1}}) +assert read()['method'] == 'session/new' +send({'id':2,'result':{'sessionId':'s'}}) +assert read()['method'] == 'session/prompt' +send({'id':3,'method':'session/request_permission','params':{'sessionId':'s','toolCall':{'title':'dev__shell','rawInput':{'command':'printf %s BUZZ_OK > marker.txt','timeout_ms':120000,'workdir':None}},'options':[{'kind':'allow_once','optionId':'yes'}]}}) +r = read() +assert r['id'] == 3 and r['result']['outcome'] == {'outcome':'selected','optionId':'yes'} +for kind,text in [('agent_thought_chunk','NOT VISIBLE'),('agent_message_chunk','PONG')]: + send({'method':'session/update','params':{'sessionId':'s','update':{'sessionUpdate':kind,'content':{'type':'text','text':text}}}}) +send({'id':3,'result':{'stopReason':'end_turn'}}) +"#; + let mut child = Command::new("python3") + .args(["-c", script]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let home = tempfile::tempdir().unwrap(); + let result = tokio::time::timeout( + Duration::from_secs(10), + drive_acp(&mut child, "test", &[], home.path(), Some("marker.txt")), + ) + .await + .unwrap(); + assert_eq!(result.unwrap(), "PONG"); + assert!(child.wait().await.unwrap().success()); +} diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index 7356cd7fc0..a4f5863509 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -176,7 +176,7 @@ fn advance_mesh_status_cursor( Ok(cursor) } -async fn query_mesh_discovery_events_at( +pub(super) async fn query_mesh_discovery_events_at( state: &AppState, relay_url: &str, ) -> Result, String> { @@ -484,7 +484,7 @@ pub async fn mesh_start_node( let started = mesh_llm::DesktopMeshRuntime::start(request) .await .map_err(|error| format!("{error:#}"))?; - let status = match started.status().await { + let mut status = match started.status().await { Ok(status) => status, Err(error) => { let cleanup = started.stop().await; @@ -520,11 +520,23 @@ pub async fn mesh_start_node( // (leaves a warming node alone) can loop a slow-but-alive node, and an // unstartable config fails earlier in `start()`. Probe is informational. save_mesh_sharing_config(&app, config)?; - if let Err(error) = wait_for_mesh_inference(&config.model_id).await { - eprintln!( - "buzz-mesh: node started but inference is not ready yet ({error}); \ - leaving it to warm up (Share Compute stays armed for next launch)" - ); + match wait_for_mesh_inference(&config.model_id).await { + Ok(()) => { + // The status captured immediately after runtime installation can + // still say `starting`. Re-read after the readiness probe so the + // command response reflects the successful boot the user waited + // for instead of pinning the UI's transition indicator. + let runtime = state.mesh_llm_runtime.lock().await; + if let Some(running) = runtime.as_ref() { + status = running.status().await.map_err(|error| error.to_string())?; + } + } + Err(error) => { + eprintln!( + "buzz-mesh: node started but inference is not ready yet ({error}); \ + leaving it to warm up (Share Compute stays armed for next launch)" + ); + } } } mesh_llm::publish_current_status_once(&app, "start").await; diff --git a/desktop/src-tauri/src/commands/mesh_snapshot.rs b/desktop/src-tauri/src/commands/mesh_snapshot.rs new file mode 100644 index 0000000000..389e228439 --- /dev/null +++ b/desktop/src-tauri/src/commands/mesh_snapshot.rs @@ -0,0 +1,45 @@ +//! `mesh_snapshot` — community-wide shared-compute snapshot for the UI. +//! +//! Separate file from `commands/mesh_llm.rs` deliberately: that module is at +//! the 1000-line file ceiling enforced by `desktop/scripts/check-file-sizes.mjs`. +//! +//! Read-only. Queries the same member-status + membership filters routing uses, +//! then projects them through [`mesh_llm::snapshot_from_events`], which applies +//! the membership/binding/freshness rules. This never selects a serve target — +//! routing stays with `availability_from_events`. + +use tauri::State; + +use crate::app_state::AppState; +use crate::mesh_llm; + +type CmdResult = Result; + +/// Snapshot of who is sharing compute in this community right now. +/// +/// An empty mesh is a normal state, not an error: the snapshot carries a +/// `reason` string instead of failing, so the card can render an honest empty +/// state. A genuine relay/transport failure still returns `Err`. +#[tauri::command] +pub async fn mesh_snapshot(state: State<'_, AppState>) -> CmdResult { + // Reuse discovery's author-scoped, composite-cursor pagination. A direct + // two-filter query stops at the status filter's 100-event page size and + // silently undercounts larger communities. + let relay_url = crate::relay::relay_ws_url_with_override(&state); + let events = super::mesh_llm::query_mesh_discovery_events_at(&state, &relay_url) + .await + .map_err(|error| format!("Shared compute status query failed: {error}"))?; + + // Identify this member's own device so the card can say "including yours". + // A missing/locked identity is not fatal here — the snapshot is still + // useful, just without self-attribution. + let self_pubkey = state + .signing_keys() + .ok() + .map(|keys| keys.public_key().to_hex()); + + Ok(mesh_llm::snapshot_from_events( + events, + self_pubkey.as_deref(), + )) +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index c8184a0103..b56cfafc94 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -43,6 +43,8 @@ mod media_voice_note; pub(crate) mod mesh_llm; #[cfg(feature = "mesh-llm")] pub(crate) mod mesh_readiness; +#[cfg(feature = "mesh-llm")] +mod mesh_snapshot; mod messages; mod notifications; mod observer_archive; @@ -106,6 +108,8 @@ pub use media_fetch_cancellation::*; pub use media_raw::*; #[cfg(feature = "mesh-llm")] pub use mesh_llm::*; +#[cfg(feature = "mesh-llm")] +pub use mesh_snapshot::*; pub use messages::*; pub use notifications::*; pub use observer_archive::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 43d7b03857..fe8fdfe6c7 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -728,6 +728,7 @@ pub fn run() { mesh_serving_usage, mesh_installed_models, mesh_model_catalog, + mesh_snapshot, update_managed_agent, discover_backend_providers, probe_backend_provider, diff --git a/desktop/src-tauri/src/mesh_llm/discovery.rs b/desktop/src-tauri/src/mesh_llm/discovery.rs index 2df4a7f8c5..8629ed62c9 100644 --- a/desktop/src-tauri/src/mesh_llm/discovery.rs +++ b/desktop/src-tauri/src/mesh_llm/discovery.rs @@ -12,7 +12,7 @@ use super::{dedupe_models, MeshAvailability, MeshModelOption, MeshServeTarget, M pub(super) const STATUS_FRESHNESS_SECS: u64 = 120; pub(crate) const MESH_STATUS_PAGE_SIZE: usize = 100; -fn status_is_fresh(event: &nostr::Event, now: u64) -> bool { +pub(super) fn status_is_fresh(event: &nostr::Event, now: u64) -> bool { event .created_at .as_secs() @@ -63,7 +63,7 @@ pub fn owner_ids_from_events(events: &[nostr::Event]) -> Vec { ids } -fn latest_membership_list(events: &[nostr::Event]) -> Option> { +pub(super) fn latest_membership_list(events: &[nostr::Event]) -> Option> { events .iter() .filter(|event| event.kind.as_u16() == 13_534) @@ -108,7 +108,7 @@ pub(crate) fn has_membership_snapshot(events: &[nostr::Event]) -> bool { events.iter().any(|event| event.kind.as_u16() == 13_534) } -fn owner_id_from_status_event(event: &nostr::Event) -> Option { +pub(super) fn owner_id_from_status_event(event: &nostr::Event) -> Option { let content = serde_json::from_str::(&event.content).ok()?; let owner_id = content .get("ownerId") @@ -136,7 +136,7 @@ fn owner_id_from_status_event(event: &nostr::Event) -> Option { Some(owner_id.to_string()) } -fn endpoint_binding_is_valid(event: &nostr::Event, content: &serde_json::Value) -> bool { +pub(super) fn endpoint_binding_is_valid(event: &nostr::Event, content: &serde_json::Value) -> bool { let Some(endpoint_tokens) = super::identity::advertised_endpoint_tokens(content) else { return false; }; diff --git a/desktop/src-tauri/src/mesh_llm/mod.rs b/desktop/src-tauri/src/mesh_llm/mod.rs index daab3c4b1c..57b9783f55 100644 --- a/desktop/src-tauri/src/mesh_llm/mod.rs +++ b/desktop/src-tauri/src/mesh_llm/mod.rs @@ -13,6 +13,9 @@ pub(crate) use discovery::{ }; use discovery::{device_name_from_status, endpoint_id_from_status, enrich_status_payload_identity}; +mod snapshot; +pub use snapshot::{snapshot_from_events, MeshSnapshot}; + mod catalog; pub(crate) use catalog::canonical_curated_model_id; pub use catalog::{model_catalog, MeshModelCatalog}; diff --git a/desktop/src-tauri/src/mesh_llm/snapshot.rs b/desktop/src-tauri/src/mesh_llm/snapshot.rs new file mode 100644 index 0000000000..b76b4bd12d --- /dev/null +++ b/desktop/src-tauri/src/mesh_llm/snapshot.rs @@ -0,0 +1,352 @@ +//! Community-wide mesh snapshot — an **allowlisted** projection of the member +//! status notes already on the relay. +//! +//! Every sharing member already publishes its whole SDK status payload as +//! kind:30003 content (see `mod.rs::status_report_payload`), but +//! `availability_from_events` reads only `serveTargets`/`models` and discards +//! the rest. This module projects the few extra fields the Share-compute UI +//! needs — per-device shared capacity, a coarse state, and a stable label — +//! and **nothing else**. +//! +//! Deliberately narrow, for two reasons: +//! 1. Routing must keep using [`super::availability_from_events`]. This +//! snapshot is presentation-only and must never select a serve target. +//! 2. The published payload also carries a hardware fingerprint, peer +//! topology, and operational counters. Naming each field we surface keeps +//! the UI from quietly growing into the rest of it. +//! +//! Capacity uses the payload's `my_vram_gb`, which mesh-llm derives from a +//! `vram_bytes` already clamped to the member's own `--max-vram` cap. Raw +//! `gpus[].vram_bytes` is *not* capped, so summing GPUs would advertise more +//! than the member agreed to share. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use super::discovery::{ + device_name_from_status, endpoint_binding_is_valid, endpoint_id_from_status, + latest_membership_list, owner_id_from_status_event, status_is_fresh, STATUS_FRESHNESS_SECS, +}; +use super::{MeshServeTarget, MESH_STATUS_KIND}; + +/// Coarse per-device state. Intentionally not the SDK's `NodeState`: the UI +/// only needs "is this device giving compute, warming up, or just consuming?" +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum MeshDeviceState { + /// Advertising at least one routable model. + Serving, + /// Serve-mode, model not ready yet. + Loading, + /// Serve-mode participant with no model work (contributes capacity only). + Standby, + /// Client-mode: consuming someone else's compute, not sharing. + Consuming, +} + +/// One device visible on the mesh. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct MeshSnapshotDevice { + /// Stable endpoint/device id, when the note carries one. + pub device_id: Option, + /// Human label: hostname when published, else a short endpoint id. + pub label: String, + /// Shared AI memory in GB, honoring the member's own cap. `None` when the + /// member is not currently running a node (a stopped member publishes only + /// its owner binding). + pub capacity_gb: Option, + /// Models this device is advertising right now. + pub models: Vec, + pub state: MeshDeviceState, + /// Whether this is the local member's own device. + pub is_self: bool, + /// Community member who signed this status note. Safe for profile lookup; + /// endpoint tokens, owner signatures, and raw hardware remain excluded. + pub member_pubkey: Option, + /// Relay event creation time, in Unix seconds. + pub reported_at: Option, + /// Approximate hosted-model footprint reported by MeshLLM, when available. + pub model_size_gb: Option, +} + +/// What the community's shared compute looks like right now. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct MeshSnapshot { + /// Devices advertising at least one routable model. + pub sharing_device_count: usize, + /// Unique current members with at least one ready serving device. + pub contributor_member_count: usize, + /// Summed shared capacity across sharing devices. `None` when no sharing + /// device published a figure — the UI then shows a count with no GB, never + /// a misleading `0 GB`. + pub shared_capacity_gb: Option, + /// Summed model footprint only when every sharing device reports it. + /// `None` preserves mixed-version honesty instead of presenting a partial sum. + pub allocated_capacity_gb: Option, + /// Distinct models ready to use, deduped across devices. + pub models: Vec, + /// Per-device rows, for the topology view. Sharing devices first. + pub devices: Vec, + /// True when the local member's own device is among the sharing devices. + pub includes_self: bool, + /// Members in the community right now (NIP-43 roster size). + /// + /// The denominator for "N of M sharing". Deliberately NOT used to estimate + /// unshared capacity: a member who never starts a node publishes no status + /// note, so their hardware is unknown by design — they never consented to + /// disclose it. Ghost nodes in the topology are a count, never GB. + pub member_count: usize, + /// When this projection was evaluated, in Unix seconds. + pub observed_at: u64, + /// Maximum age of a relay-reported serving status. + pub freshness_seconds: u64, + /// Why the snapshot is empty, when it is. Never a hard error: an empty + /// mesh is a normal, expected state. + pub reason: Option, +} + +impl MeshSnapshot { + fn empty(reason: impl Into) -> Self { + Self { + observed_at: nostr::Timestamp::now().as_secs(), + freshness_seconds: STATUS_FRESHNESS_SECS, + reason: Some(reason.into()), + ..Default::default() + } + } +} + +fn f64_field(content: &serde_json::Value, key: &str) -> Option { + content + .get(key) + .and_then(serde_json::Value::as_f64) + .filter(|value| value.is_finite() && *value > 0.0) +} + +/// Coarse state from the published `node_state`, falling back to whether the +/// note actually advertises routable targets. +fn device_state(content: &serde_json::Value, has_targets: bool) -> MeshDeviceState { + let raw = content + .get("node_state") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .unwrap_or_default() + .to_ascii_lowercase(); + match raw.as_str() { + "serving" => MeshDeviceState::Serving, + "loading" => MeshDeviceState::Loading, + "client" => MeshDeviceState::Consuming, + "standby" => MeshDeviceState::Standby, + // Unknown/absent: trust the routable evidence rather than guessing. + _ if has_targets => MeshDeviceState::Serving, + _ => MeshDeviceState::Standby, + } +} + +/// Project validated member status notes into a presentation snapshot. +/// +/// Applies the same trust rules as routing — current NIP-43 membership, owner +/// binding, endpoint binding, and [`status_is_fresh`] — so a stale or +/// non-member note can never inflate the headline. Pure and total so it can be +/// unit-tested without a relay. +pub fn snapshot_from_events( + events: Vec, + self_member_pubkey: Option<&str>, +) -> MeshSnapshot { + if events.is_empty() { + return MeshSnapshot::empty("No shared compute has been published yet"); + } + let Some(members) = latest_membership_list(&events) else { + return MeshSnapshot::empty("Waiting for the current member roster"); + }; + + let self_pubkey = self_member_pubkey.map(str::to_ascii_lowercase); + let now = nostr::Timestamp::now().as_secs(); + // Status notes are replaceable per owner id, so key on that: two devices + // signed in as the same member each publish their own note. + let mut by_owner = BTreeMap::::new(); + + for event in events { + if event.kind.as_u16() as u64 != MESH_STATUS_KIND + || !status_is_fresh(&event, now) + || !members.contains(&event.pubkey.to_hex().to_ascii_lowercase()) + { + continue; + } + let Ok(content) = serde_json::from_str::(&event.content) else { + continue; + }; + let Some(owner_id) = owner_id_from_status_event(&event) else { + continue; + }; + if !endpoint_binding_is_valid(&event, &content) { + continue; + } + + // Reuse the routing-side target parse so "sharing" here means exactly + // what it means to the router: a validated, advertisable endpoint. + let targets = content + .get("serveTargets") + .or_else(|| content.get("serve_targets")) + .cloned() + .and_then(|value| serde_json::from_value::>(value).ok()) + .unwrap_or_default() + .into_iter() + .filter(|target| { + super::transport_policy::validate_advertised_endpoint(&target.endpoint_addr).is_ok() + }) + .collect::>(); + + let mut models = targets + .iter() + .map(|target| target.model_id.clone()) + .collect::>(); + models.sort(); + models.dedup(); + + let endpoint_id = endpoint_id_from_status(&content, None); + let is_self = self_pubkey + .as_deref() + .is_some_and(|pubkey| event.pubkey.to_hex().to_ascii_lowercase() == pubkey); + + by_owner.insert( + owner_id, + MeshSnapshotDevice { + label: device_name_from_status(&content, endpoint_id.as_deref()) + .unwrap_or_else(|| "Unknown device".to_string()), + device_id: endpoint_id, + capacity_gb: f64_field(&content, "my_vram_gb"), + state: device_state(&content, !models.is_empty()), + models, + is_self, + member_pubkey: Some(event.pubkey.to_hex()), + reported_at: Some(event.created_at.as_secs()), + model_size_gb: f64_field(&content, "model_size_gb"), + }, + ); + } + + let mut devices = by_owner.into_values().collect::>(); + // Sharing devices first, then the local device, then by label so the list + // is stable across polls (no jitter in the topology view). + devices.sort_by(|a, b| { + let sharing = |d: &MeshSnapshotDevice| d.state == MeshDeviceState::Serving; + sharing(b) + .cmp(&sharing(a)) + .then_with(|| b.is_self.cmp(&a.is_self)) + .then_with(|| a.label.cmp(&b.label)) + }); + + let sharing = devices + .iter() + .filter(|device| device.state == MeshDeviceState::Serving) + .collect::>(); + let sharing_device_count = sharing.len(); + let contributor_member_count = sharing + .iter() + .filter_map(|device| device.member_pubkey.as_ref()) + .collect::>() + .len(); + let includes_self = sharing.iter().any(|device| device.is_self); + // Sum only reported figures; keep `None` when nobody reported one so the + // UI can drop the number instead of printing `0 GB`. + let shared_capacity_gb = sharing + .iter() + .filter_map(|device| device.capacity_gb) + .fold(None::, |acc, gb| Some(acc.unwrap_or(0.0) + gb)); + let allocated_capacity_gb = (!sharing.is_empty() + && sharing.iter().all(|device| device.model_size_gb.is_some())) + .then(|| { + sharing + .iter() + .filter_map(|device| device.model_size_gb) + .sum() + }); + let mut models = sharing + .iter() + .flat_map(|device| device.models.iter().cloned()) + .collect::>(); + models.sort(); + models.dedup(); + + let reason = if sharing_device_count == 0 { + Some("No one is sharing compute yet".to_string()) + } else { + None + }; + + MeshSnapshot { + member_count: members.len(), + contributor_member_count, + sharing_device_count, + shared_capacity_gb, + allocated_capacity_gb, + models, + devices, + includes_self, + observed_at: now, + freshness_seconds: STATUS_FRESHNESS_SECS, + reason, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_events_report_a_reason_not_an_error() { + let snapshot = snapshot_from_events(Vec::new(), None); + assert_eq!(snapshot.sharing_device_count, 0); + assert_eq!(snapshot.shared_capacity_gb, None); + assert!(snapshot.reason.is_some()); + assert_eq!(snapshot.freshness_seconds, STATUS_FRESHNESS_SECS); + assert!(snapshot.observed_at > 0); + } + + #[test] + fn missing_membership_snapshot_is_not_treated_as_an_empty_mesh() { + // A relay gap must not read as "nobody is sharing". + let event = nostr::EventBuilder::new(nostr::Kind::Custom(30003), "{}") + .sign_with_keys(&nostr::Keys::generate()) + .expect("test event signs"); + let snapshot = snapshot_from_events(vec![event], None); + assert_eq!( + snapshot.reason.as_deref(), + Some("Waiting for the current member roster") + ); + } + + #[test] + fn unknown_node_state_falls_back_to_routable_evidence() { + assert_eq!( + device_state(&serde_json::json!({}), true), + MeshDeviceState::Serving + ); + assert_eq!( + device_state(&serde_json::json!({}), false), + MeshDeviceState::Standby + ); + assert_eq!( + device_state(&serde_json::json!({"node_state": "client"}), false), + MeshDeviceState::Consuming + ); + } + + #[test] + fn capacity_ignores_zero_and_nonfinite_reports() { + // A node that has not surveyed yet publishes 0.0; that must not count + // as a real reported capacity. + assert_eq!( + f64_field(&serde_json::json!({"my_vram_gb": 0.0}), "my_vram_gb"), + None + ); + assert_eq!( + f64_field(&serde_json::json!({"my_vram_gb": 36.5}), "my_vram_gb"), + Some(36.5) + ); + } +} diff --git a/desktop/src-tauri/src/mesh_llm_stubs.rs b/desktop/src-tauri/src/mesh_llm_stubs.rs index e8c13f48ea..5b93cf8c76 100644 --- a/desktop/src-tauri/src/mesh_llm_stubs.rs +++ b/desktop/src-tauri/src/mesh_llm_stubs.rs @@ -42,3 +42,8 @@ pub async fn mesh_installed_models( pub async fn mesh_model_catalog() -> CmdResult { Err("mesh-llm feature not enabled".to_string()) } + +#[tauri::command] +pub async fn mesh_snapshot(_state: State<'_, AppState>) -> CmdResult { + Err("mesh-llm feature not enabled".to_string()) +} diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 7f1f73b175..67eba2c60f 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -381,6 +381,8 @@ buzz messages send --channel --reply-to \ - Rust: `definition_validation` and inbound persona tests pin the shared Unicode/control-character policy at local, import, publish, and sync gates. +16. **Community-mesh agent creation uses the normal create dialog with a named preset.** The Compute settings CTA requests the `community-mesh` create preset; the app-level create-dialog owner resolves it to Buzz Agent with the `relay-mesh` provider and `auto` model, then passes those values through `AgentDialog`. Keep this as a preset of the canonical create flow—never add a second mesh-specific form or bypass normal runtime catalog validation and persistence. + ## Keep this file true **If you change how agent configuration is modeled, rendered, persisted, diff --git a/desktop/src/features/agents/openCreateAgentEvent.ts b/desktop/src/features/agents/openCreateAgentEvent.ts index 78bf59eb9d..4504dbfb77 100644 --- a/desktop/src/features/agents/openCreateAgentEvent.ts +++ b/desktop/src/features/agents/openCreateAgentEvent.ts @@ -3,6 +3,7 @@ const OPEN_CREATE_AGENT_EVENT = "buzz:open-create-agent"; export type OpenCreateAgentOptions = { channelId?: string; channelName?: string; + preset?: "community-mesh"; }; let pendingOpenCreateAgent: OpenCreateAgentOptions | null = null; diff --git a/desktop/src/features/agents/ui/RequestedAgentCreateDialogs.tsx b/desktop/src/features/agents/ui/RequestedAgentCreateDialogs.tsx index 4b04de638d..f63b598776 100644 --- a/desktop/src/features/agents/ui/RequestedAgentCreateDialogs.tsx +++ b/desktop/src/features/agents/ui/RequestedAgentCreateDialogs.tsx @@ -5,6 +5,7 @@ import { subscribeOpenCreateAgent, type OpenCreateAgentOptions, } from "@/features/agents/openCreateAgentEvent"; +import type { CreatePersonaInput } from "@/shared/api/types"; import { AgentDialog } from "./AgentDialog"; import { usePersonaActions } from "./usePersonaActions"; @@ -16,9 +17,23 @@ export function RequestedAgentCreateDialogs() { name: string; } | null>(null); const [isOpen, setIsOpen] = React.useState(false); + const [initialValues, setInitialValues] = + React.useState(null); const openCreate = React.useEffectEvent((options: OpenCreateAgentOptions) => { personas.prepareCreate(); + setInitialValues( + options.preset === "community-mesh" + ? { + displayName: "Community agent", + avatarUrl: "", + systemPrompt: "", + runtime: "buzz-agent", + provider: "relay-mesh", + model: "auto", + } + : null, + ); setTargetChannel( options.channelId && options.channelName ? { id: options.channelId, name: options.channelName } @@ -42,12 +57,14 @@ export function RequestedAgentCreateDialogs() { ? personas.createPersonaMutation.error : null } + initialValues={initialValues} isDefinitionPending={personas.isPending} mode="definition" onOpenChange={(open) => { if (!open) { setIsOpen(false); setTargetChannel(null); + setInitialValues(null); } }} onSubmitDefinition={(input, intent, backendIntent) => diff --git a/desktop/src/features/mesh-compute/communityComputeFixtures.ts b/desktop/src/features/mesh-compute/communityComputeFixtures.ts new file mode 100644 index 0000000000..e88f672680 --- /dev/null +++ b/desktop/src/features/mesh-compute/communityComputeFixtures.ts @@ -0,0 +1,234 @@ +import type { + CommunityComputeDeviceInput, + CommunityComputeSnapshotInput, +} from "./communityComputeMapModel"; + +export type CommunityComputeTutorialFixtureId = + | "empty" + | "single-device" + | "small-company" + | "large-dense-500"; + +export type CommunityComputeTutorialFixture = { + id: CommunityComputeTutorialFixtureId; + title: string; + description: string; + snapshot: CommunityComputeSnapshotInput; +}; + +const MODEL_REFS = [ + "unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_M", + "Qwen/Qwen3.5-35B-A3B-GGUF:Q4_K_M", + "mistralai/Ministral-3-14B-Instruct-GGUF:Q4_K_M", + "bartowski/Llama-3.3-70B-Instruct-GGUF:Q4_K_M", +] as const; + +export const EMPTY_COMMUNITY_COMPUTE_FIXTURE: CommunityComputeTutorialFixture = + { + id: "empty", + title: "New community", + description: "No member has shared compute yet.", + snapshot: { + sharingDeviceCount: 0, + contributorMemberCount: 0, + sharedCapacityGb: null, + allocatedCapacityGb: null, + models: [], + devices: [], + includesSelf: false, + memberCount: 8, + reason: "No sharing devices found", + }, + }; + +export const SINGLE_DEVICE_COMMUNITY_COMPUTE_FIXTURE: CommunityComputeTutorialFixture = + { + id: "single-device", + title: "First deployment", + description: "One member is sharing a single workstation.", + snapshot: snapshotFromDevices( + [ + device({ + index: 0, + id: "studio-workstation", + label: "Morgan’s Studio", + capacityGb: 24, + modelSizeGb: 18, + isSelf: true, + }), + ], + 1, + ), + }; + +export const SMALL_COMPANY_COMMUNITY_COMPUTE_FIXTURE: CommunityComputeTutorialFixture = + { + id: "small-company", + title: "Small company", + description: "A mixed fleet across a small distributed team.", + snapshot: snapshotFromDevices( + [ + ...[ + { capacityGb: 24, modelSizeGb: 18 }, + { capacityGb: 24, modelSizeGb: 18 }, + { capacityGb: 36, modelSizeGb: 28 }, + { capacityGb: 36, modelSizeGb: 28 }, + { capacityGb: 48, modelSizeGb: 36 }, + { capacityGb: 48, modelSizeGb: 36 }, + { capacityGb: 64, modelSizeGb: 48 }, + { capacityGb: 64, modelSizeGb: 48 }, + { capacityGb: 96, modelSizeGb: 72 }, + { capacityGb: 96, modelSizeGb: 72 }, + ].map(({ capacityGb, modelSizeGb }, index) => + device({ + index, + id: `team-macbook-${String(index + 1).padStart(2, "0")}`, + label: `Team MacBook ${String(index + 1).padStart(2, "0")}`, + capacityGb, + modelSizeGb, + isSelf: index === 0, + }), + ), + device({ + index: 10, + id: "intensive-model-host-a", + label: "Intensive model host A", + capacityGb: 768, + modelRef: "unsloth/Kimi-K2-Thinking-GGUF:UD-Q4_K_XL", + modelSizeGb: 646, + }), + device({ + index: 11, + id: "intensive-model-host-b", + label: "Intensive model host B", + capacityGb: 1_440, + modelRef: "unsloth/DeepSeek-V3.2-GGUF:UD-Q4_K_XL", + modelSizeGb: 1_000, + }), + ], + 24, + ), + }; + +/** Deterministic stress fixture using only the production snapshot contract. */ +export const LARGE_DENSE_COMMUNITY_COMPUTE_FIXTURE: CommunityComputeTutorialFixture = + { + id: "large-dense-500", + title: "Large company", + description: + "Five hundred deterministic reporting devices for density testing.", + snapshot: createLargeDenseSnapshot(500), + }; + +export const COMMUNITY_COMPUTE_TUTORIAL_FIXTURES: Readonly< + Record +> = { + empty: EMPTY_COMMUNITY_COMPUTE_FIXTURE, + "single-device": SINGLE_DEVICE_COMMUNITY_COMPUTE_FIXTURE, + "small-company": SMALL_COMPANY_COMMUNITY_COMPUTE_FIXTURE, + "large-dense-500": LARGE_DENSE_COMMUNITY_COMPUTE_FIXTURE, +}; + +export function createLargeDenseSnapshot( + deviceCount = 500, +): CommunityComputeSnapshotInput { + const count = Math.max(0, Math.floor(deviceCount)); + const capacities = [8, 16, 24, 32, 48, 64, 96, 128] as const; + const devices = Array.from({ length: count }, (_, index) => { + const capacityGb = capacities[index % capacities.length]; + const state = + index % 41 === 0 ? "loading" : index % 17 === 0 ? "standby" : "serving"; + const largeIndex = [49, 149, 249, 349, 449, 499].indexOf(index); + const largeSizes = [170, 230, 350, 480, 700, 1_000] as const; + const largeCapacities = [192, 256, 384, 512, 768, 1_440] as const; + const modelSizeGb = + largeIndex >= 0 + ? largeSizes[largeIndex] + : roundCapacity(capacityGb * 0.72); + return device({ + index, + id: `dense-device-${String(index + 1).padStart(4, "0")}`, + label: + largeIndex >= 0 + ? `${largeCapacities[largeIndex]} GB model host` + : `Compute ${String(index + 1).padStart(4, "0")}`, + capacityGb: largeIndex >= 0 ? largeCapacities[largeIndex] : capacityGb, + modelRef: + largeIndex >= 0 + ? `community/Large-${modelSizeGb}GB-Instruct:FP8` + : undefined, + modelSizeGb, + state, + models: state === "serving" ? undefined : [], + isSelf: index === 0, + }); + }); + return snapshotFromDevices(devices, Math.ceil(count * 1.37)); +} + +type DeviceOptions = { + index: number; + id: string; + label: string; + capacityGb: number; + modelRef?: string; + modelSizeGb?: number; + models?: string[]; + state?: CommunityComputeDeviceInput["state"]; + isSelf?: boolean; +}; + +function device(options: DeviceOptions): CommunityComputeDeviceInput { + const model = + options.modelRef ?? MODEL_REFS[options.index % MODEL_REFS.length]; + return { + deviceId: options.id, + label: options.label, + capacityGb: options.capacityGb, + models: options.models ?? [model], + state: options.state ?? "serving", + isSelf: options.isSelf ?? false, + memberPubkey: `tutorial-member-${String(options.index + 1).padStart(4, "0")}`, + reportedAt: 1_786_032_000, + modelSizeGb: options.modelSizeGb ?? null, + }; +} + +function roundCapacity(value: number): number { + return Math.round(value * 10) / 10; +} + +function snapshotFromDevices( + devices: CommunityComputeDeviceInput[], + memberCount: number, +): CommunityComputeSnapshotInput { + const serving = devices.filter( + (item) => item.state === "serving" && item.models.length > 0, + ); + const models = [...new Set(serving.flatMap((item) => item.models))].sort(); + const sharedCapacityGb = completeSum(serving.map((item) => item.capacityGb)); + const allocatedCapacityGb = completeSum( + serving.map((item) => item.modelSizeGb ?? null), + ); + return { + sharingDeviceCount: serving.length, + contributorMemberCount: new Set( + serving.map((item) => item.memberPubkey ?? item.deviceId), + ).size, + sharedCapacityGb, + allocatedCapacityGb, + models, + devices, + includesSelf: serving.some((item) => item.isSelf), + memberCount, + observedAt: 1_786_032_000, + freshnessSeconds: 120, + reason: serving.length === 0 ? "No sharing devices found" : null, + }; +} + +function completeSum(values: readonly (number | null)[]): number | null { + return values.length > 0 && values.every((value) => value !== null) + ? values.reduce((total, value) => total + (value ?? 0), 0) + : null; +} diff --git a/desktop/src/features/mesh-compute/communityComputeHexLayout.test.mjs b/desktop/src/features/mesh-compute/communityComputeHexLayout.test.mjs new file mode 100644 index 0000000000..ee14e9032d --- /dev/null +++ b/desktop/src/features/mesh-compute/communityComputeHexLayout.test.mjs @@ -0,0 +1,358 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + hexCoordinateKey, + hexDistance, + hexNeighbours, + hexTerritoryBoundaryEdges, + hexTerritoryInteriorEdges, + isContiguousHexTerritory, + layoutCommunityComputeHexTerritories, +} from "./communityComputeHexLayout.ts"; +import { LARGE_DENSE_COMMUNITY_COMPUTE_FIXTURE } from "./communityComputeFixtures.ts"; +import { deriveCommunityComputeMapModel } from "./communityComputeMapModel.ts"; + +function assertStructuralInvariants(layout) { + const occupied = new Set(layout.cells.map(hexCoordinateKey)); + assert.equal(occupied.size, layout.cells.length, "cells must not overlap"); + assert.ok( + layout.territories.every((territory) => + isContiguousHexTerritory(territory.cells), + ), + "every territory must be contiguous", + ); + if (layout.cells.length > 0) { + const reached = new Set([hexCoordinateKey(layout.cells[0])]); + const queue = [layout.cells[0]]; + for (let cursor = 0; cursor < queue.length; cursor += 1) { + for (const neighbour of hexNeighbours(queue[cursor])) { + const key = hexCoordinateKey(neighbour); + if (occupied.has(key) && !reached.has(key)) { + reached.add(key); + queue.push(neighbour); + } + } + } + assert.equal( + reached.size, + occupied.size, + "occupied mesh must be connected", + ); + } +} + +function organicGaps(layout) { + const occupied = new Set(layout.cells.map(hexCoordinateKey)); + const empty = new Map(); + for (const cell of layout.cells) { + for (const neighbour of hexNeighbours(cell)) { + const key = hexCoordinateKey(neighbour); + if (!occupied.has(key)) empty.set(key, neighbour); + } + } + return [...empty.values()].filter( + (cell) => + hexNeighbours(cell).filter((neighbour) => + occupied.has(hexCoordinateKey(neighbour)), + ).length >= 2, + ); +} + +function assertBalancedSparseFrontier(layout, deploymentCount) { + const points = layout.cells.map((cell) => ({ + x: Math.sqrt(3) * (cell.q + cell.r / 2), + y: 1.5 * cell.r, + })); + const width = + Math.max(...points.map(({ x }) => x)) - + Math.min(...points.map(({ x }) => x)) + + Math.sqrt(3); + const height = + Math.max(...points.map(({ y }) => y)) - + Math.min(...points.map(({ y }) => y)) + + 2; + const aspectRatio = width / height; + assert.ok( + aspectRatio >= 0.8 && aspectRatio <= 1.3, + `${deploymentCount} deployments have unbalanced aspect ratio ${aspectRatio}`, + ); + + const radius = Math.max( + ...layout.cells.map((cell) => hexDistance(cell, { q: 0, r: 0 })), + ); + const directionalRadii = Array(6).fill(0); + for (const cell of layout.cells) { + const projections = [ + cell.q, + -cell.r, + -cell.q - cell.r, + -cell.q, + cell.r, + cell.q + cell.r, + ]; + for (const [index, projection] of projections.entries()) { + directionalRadii[index] = Math.max(directionalRadii[index], projection); + } + } + assert.ok( + Math.max(...directionalRadii) - Math.min(...directionalRadii) <= 2, + `${deploymentCount} deployments must reach every side evenly`, + ); + + const outerOccupancy = + layout.cells.filter((cell) => hexDistance(cell, { q: 0, r: 0 }) === radius) + .length / + (6 * radius); + assert.ok( + outerOccupancy >= 0.1 && outerOccupancy <= 0.55, + `${deploymentCount} deployments need a sparse, notched outer edge (${outerOccupancy})`, + ); +} + +function deployments(count) { + const buckets = [1, 2, 4, 7]; + return Array.from({ length: count }, (_, index) => ({ + id: `deployment-${String(index + 1).padStart(4, "0")}`, + cellCount: buckets[index % buckets.length], + })); +} + +test("layout is deterministic and independent of input order", () => { + const input = deployments(80); + const first = layoutCommunityComputeHexTerritories(input); + const second = layoutCommunityComputeHexTerritories([...input].reverse()); + assert.deepEqual(first, second); +}); + +test("growing layouts stay balanced with sparse outer frontiers", () => { + for (const count of [24, 250, 1_000]) { + const layout = layoutCommunityComputeHexTerritories(deployments(count)); + assertStructuralInvariants(layout); + assertBalancedSparseFrontier(layout, count); + assert.ok( + organicGaps(layout).length > 0, + `${count} deployments should leave deterministic pockets or edge gaps`, + ); + } +}); + +test("canonical territory order remains stable when previous placement is reused", () => { + const input = deployments(60); + const initial = layoutCommunityComputeHexTerritories(input); + const next = layoutCommunityComputeHexTerritories([...input].reverse(), { + previous: initial, + }); + assert.deepEqual( + next.territories.map(({ deploymentId }) => deploymentId), + initial.territories.map(({ deploymentId }) => deploymentId), + ); +}); + +test("payload changes preserve positions because IDs own the slots", () => { + const initial = deployments(40); + const changed = initial.map((deployment) => ({ + ...deployment, + label: `Changed ${deployment.id}`, + health: "degraded", + })); + assert.deepEqual( + layoutCommunityComputeHexTerritories(initial), + layoutCommunityComputeHexTerritories(changed), + ); +}); + +test("surviving IDs keep positions as deployments are added and removed", () => { + const initialInput = deployments(40); + const initial = layoutCommunityComputeHexTerritories(initialInput); + const nextInput = [ + ...initialInput.filter((_, index) => index % 4 !== 0), + ...deployments(6).map((item, index) => ({ + ...item, + id: `new-deployment-${index}`, + })), + ]; + const next = layoutCommunityComputeHexTerritories(nextInput, { + previous: initial, + }); + const initialCenters = new Map( + initial.territories.map((territory) => [ + territory.deploymentId, + territory.center, + ]), + ); + + for (const territory of next.territories) { + const oldCenter = initialCenters.get(territory.deploymentId); + if (oldCenter !== undefined) { + assert.deepEqual(territory.center, oldCenter); + } + } + assert.equal( + new Set(next.cells.map(hexCoordinateKey)).size, + next.cells.length, + ); +}); + +test("every supported territory shape is contiguous", () => { + const layout = layoutCommunityComputeHexTerritories([ + { id: "one", cellCount: 1 }, + { id: "two", cellCount: 2 }, + { id: "four", cellCount: 4 }, + { id: "seven", cellCount: 7 }, + ]); + const expected = new Map([ + ["one", 1], + ["two", 2], + ["four", 4], + ["seven", 7], + ]); + + for (const territory of layout.territories) { + assert.equal(territory.cells.length, expected.get(territory.deploymentId)); + assert.ok( + isContiguousHexTerritory(territory.cells), + `${territory.deploymentId} must be contiguous`, + ); + } +}); + +test("fused boundaries omit interior edges for 1, 2, 4, and 7 cells", () => { + const shapes = [ + [{ q: 0, r: 0 }], + [ + { q: 0, r: 0 }, + { q: 1, r: 0 }, + ], + [ + { q: 0, r: 0 }, + { q: 1, r: 0 }, + { q: 1, r: -1 }, + { q: 0, r: -1 }, + ], + [{ q: 0, r: 0 }, ...hexNeighbours({ q: 0, r: 0 })], + ]; + + for (const [index, cells] of shapes.entries()) { + const sharedPairs = + cells.reduce( + (total, cell) => + total + + hexNeighbours(cell).filter((neighbour) => + cells.some( + (candidate) => + hexCoordinateKey(candidate) === hexCoordinateKey(neighbour), + ), + ).length, + 0, + ) / 2; + const boundary = hexTerritoryBoundaryEdges(cells); + assert.equal(boundary.length, 6 * cells.length - 2 * sharedPairs); + + const endpointDegree = new Map(); + for (const edge of boundary) { + assert.notDeepEqual(edge.start, edge.end); + for (const point of [edge.start, edge.end]) { + const key = `${point.x.toFixed(8)},${point.y.toFixed(8)}`; + endpointDegree.set(key, (endpointDegree.get(key) ?? 0) + 1); + } + } + assert.ok( + [...endpointDegree.values()].every((degree) => degree === 2), + `shape ${index} boundary must form closed loops without dangling edges`, + ); + } + + const edgeKey = (edge) => + [edge.start, edge.end] + .map((point) => `${point.x.toFixed(8)},${point.y.toFixed(8)}`) + .sort() + .join("|"); + assert.deepEqual( + hexTerritoryBoundaryEdges(shapes[3]).map(edgeKey).sort(), + hexTerritoryBoundaryEdges([...shapes[3]].reverse()) + .map(edgeKey) + .sort(), + "edge membership must not depend on cell order", + ); +}); + +test("interior edges contain each shared device boundary exactly once", () => { + const cells = [{ q: 0, r: 0 }, ...hexNeighbours({ q: 0, r: 0 })]; + const interior = hexTerritoryInteriorEdges(cells); + const edgeKey = (edge) => + [edge.start, edge.end] + .map((point) => `${point.x.toFixed(8)},${point.y.toFixed(8)}`) + .sort() + .join("|"); + + assert.equal(interior.length, 12); + assert.equal(new Set(interior.map(edgeKey)).size, interior.length); + assert.deepEqual( + interior.map(edgeKey).sort(), + hexTerritoryInteriorEdges([...cells].reverse()) + .map(edgeKey) + .sort(), + "shared edge membership must not depend on cell order", + ); + assert.deepEqual(hexTerritoryInteriorEdges([{ q: 0, r: 0 }]), []); +}); + +test("territories never emit duplicate occupied cells", () => { + const layout = layoutCommunityComputeHexTerritories(deployments(250)); + const coordinates = layout.cells.map(hexCoordinateKey); + assert.equal(new Set(coordinates).size, coordinates.length); + assert.equal( + new Set(layout.cells.map((cell) => cell.id)).size, + layout.cells.length, + ); +}); + +test("duplicate deployment IDs are rejected rather than overlapped", () => { + assert.throws( + () => + layoutCommunityComputeHexTerritories([ + { id: "same", cellCount: 1 }, + { id: "same", cellCount: 7 }, + ]), + /Duplicate community compute deployment ID: same/, + ); +}); + +test("empty layout has no fabricated bounds", () => { + assert.deepEqual(layoutCommunityComputeHexTerritories([]), { + territories: [], + cells: [], + bounds: null, + }); +}); + +test("500-deployment fixture lays out uniquely and contiguously", () => { + const model = deriveCommunityComputeMapModel( + LARGE_DENSE_COMMUNITY_COMPUTE_FIXTURE.snapshot, + ); + const input = model.deployments.map(({ id, cellCount }) => ({ + id, + cellCount, + })); + const layout = layoutCommunityComputeHexTerritories(input); + + assert.equal(layout.territories.length, model.deployments.length); + assert.equal( + layout.cells.length, + model.deployments.reduce( + (total, deployment) => total + deployment.cellCount, + 0, + ), + ); + assert.equal( + new Set(layout.cells.map(hexCoordinateKey)).size, + layout.cells.length, + ); + assert.ok( + layout.territories.every((territory) => + isContiguousHexTerritory(territory.cells), + ), + ); + assert.ok(layout.bounds !== null); +}); diff --git a/desktop/src/features/mesh-compute/communityComputeHexLayout.ts b/desktop/src/features/mesh-compute/communityComputeHexLayout.ts new file mode 100644 index 0000000000..aac860d99a --- /dev/null +++ b/desktop/src/features/mesh-compute/communityComputeHexLayout.ts @@ -0,0 +1,448 @@ +import type { HexCellCount } from "./communityComputeMapModel"; + +/** Axial coordinates for a pointy-top hex grid. */ +export type HexCoordinate = { + q: number; + r: number; +}; + +/** Cartesian point in pointy-top hex geometry. */ +export type HexPoint = { + x: number; + y: number; +}; + +/** One exposed side of a fused hex territory boundary. */ +export type HexBoundaryEdge = { + start: HexPoint; + end: HexPoint; +}; + +export type CommunityComputeLayoutInput = { + id: string; + cellCount: HexCellCount; +}; + +export type CommunityComputeHexCell = HexCoordinate & { + id: string; + deploymentId: string; + index: number; +}; + +export type CommunityComputeHexTerritory = { + deploymentId: string; + center: HexCoordinate; + cells: readonly CommunityComputeHexCell[]; +}; + +export type CommunityComputeHexBounds = { + minQ: number; + maxQ: number; + minR: number; + maxR: number; +}; + +export type CommunityComputeHexLayout = { + territories: readonly CommunityComputeHexTerritory[]; + cells: readonly CommunityComputeHexCell[]; + bounds: CommunityComputeHexBounds | null; +}; + +export type CommunityComputeHexLayoutOptions = { + /** + * Reuse centres for IDs that are still present. Supplying the last layout + * keeps a live map steady while deployments arrive, leave, or change size. + */ + previous?: CommunityComputeHexLayout | null; +}; + +const HEX_DIRECTIONS: readonly HexCoordinate[] = [ + { q: 1, r: 0 }, + { q: 1, r: -1 }, + { q: 0, r: -1 }, + { q: -1, r: 0 }, + { q: -1, r: 1 }, + { q: 0, r: 1 }, +]; + +/** + * Lay deployments out as one organic, edge-connected honeycomb. + * + * Input order never affects the result. Stable hashes choose territory + * orientation, while a deterministic low-discrepancy permutation spreads each + * ring's placements around the whole current frontier. Completed inner rings + * remain dense; only the growing outer layers carry deliberate notches. With a + * previous layout, collision-free surviving shapes retain their exact cells; + * new shapes take the first non-overlapping frontier position that shares an + * edge with the occupied mesh. + */ +export function layoutCommunityComputeHexTerritories( + deployments: readonly CommunityComputeLayoutInput[], + options: CommunityComputeHexLayoutOptions = {}, +): CommunityComputeHexLayout { + const seenIds = new Set(); + for (const deployment of deployments) { + if (seenIds.has(deployment.id)) { + throw new Error( + `Duplicate community compute deployment ID: ${deployment.id}`, + ); + } + seenIds.add(deployment.id); + } + + const ordered = [...deployments].sort( + (left, right) => + stableHash(left.id) - stableHash(right.id) || + left.id.localeCompare(right.id), + ); + // Previous cells are retained only when they remain collision-free. New + // territories search the compact spiral for the first candidate that both + // fits and touches the occupied frontier. + const previousById = new Map( + (options.previous?.territories ?? []).map((territory) => [ + territory.deploymentId, + territory, + ]), + ); + const retainableIds = connectedRetainedTerritoryIds(ordered, previousById); + const occupied = new Set(); + const territories: CommunityComputeHexTerritory[] = []; + const placementOrder = [...ordered].sort( + (left, right) => + Number(retainableIds.has(right.id)) - + Number(retainableIds.has(left.id)) || + ordered.indexOf(left) - ordered.indexOf(right), + ); + + for (const deployment of placementOrder) { + const previous = retainableIds.has(deployment.id) + ? previousById.get(deployment.id) + : undefined; + const retained = + previous?.cells.length === deployment.cellCount + ? previous.cells.map(({ q, r }) => ({ q, r })) + : null; + const retainedFits = retained?.every( + (cell) => !occupied.has(hexCoordinateKey(cell)), + ); + const orientation = stableHash(`${deployment.id}:orientation`) % 6; + let center = retainedFits ? previous?.center : undefined; + let coordinates = retainedFits ? retained : null; + + if (!coordinates || !center) { + for (let slot = 0; ; slot += 1) { + const candidate = balancedSpiralCoordinate(slot); + const offsets = territoryOffsets(deployment.cellCount, orientation); + const candidateCells = offsets.map((offset) => ({ + q: candidate.q + offset.q, + r: candidate.r + offset.r, + })); + const fits = candidateCells.every( + (cell) => !occupied.has(hexCoordinateKey(cell)), + ); + const touches = + occupied.size === 0 || + candidateCells.some((cell) => + hexNeighbours(cell).some((neighbour) => + occupied.has(hexCoordinateKey(neighbour)), + ), + ); + if (fits && touches) { + center = candidate; + coordinates = candidateCells; + break; + } + } + } + + const cells = coordinates.map(({ q, r }, index) => { + occupied.add(hexCoordinateKey({ q, r })); + return { + id: `${deployment.id}@${q},${r}`, + deploymentId: deployment.id, + index, + q, + r, + }; + }); + territories.push({ deploymentId: deployment.id, center, cells }); + } + territories.sort( + (left, right) => + ordered.findIndex((item) => item.id === left.deploymentId) - + ordered.findIndex((item) => item.id === right.deploymentId), + ); + const cells = territories.flatMap((territory) => territory.cells); + + return { territories, cells, bounds: findBounds(cells) }; +} + +/** The six axial neighbours of a cell, in stable clockwise order. */ +export function hexNeighbours(coordinate: HexCoordinate): HexCoordinate[] { + return HEX_DIRECTIONS.map((direction) => ({ + q: coordinate.q + direction.q, + r: coordinate.r + direction.r, + })); +} + +/** + * Return only the exposed unit-hex sides of a territory. + * + * Coordinates use pointy-top axial geometry. Shared sides are omitted, so the + * result can be stroked as one fused territory rather than as overlapping + * individual hexagons. Edge direction is stable and follows each cell's + * perimeter; callers may chain matching endpoints when an ordered path is + * required. + */ +export function hexTerritoryBoundaryEdges( + cells: readonly HexCoordinate[], +): HexBoundaryEdge[] { + const occupied = new Set(cells.map(hexCoordinateKey)); + const edges: HexBoundaryEdge[] = []; + + for (const cell of cells) { + for (let direction = 0; direction < HEX_DIRECTIONS.length; direction += 1) { + const neighbour = { + q: cell.q + HEX_DIRECTIONS[direction].q, + r: cell.r + HEX_DIRECTIONS[direction].r, + }; + if (!occupied.has(hexCoordinateKey(neighbour))) { + edges.push(hexEdge(cell, direction)); + } + } + } + + return edges; +} + +/** + * Return each side shared by two cells in a territory exactly once. + * + * Fused model territories do not render these edges. Split deployments use + * them to show the device boundaries without drawing inset outlines around + * every cell or accidentally doubling a shared stroke. + */ +export function hexTerritoryInteriorEdges( + cells: readonly HexCoordinate[], +): HexBoundaryEdge[] { + const occupied = new Set(cells.map(hexCoordinateKey)); + const edges: HexBoundaryEdge[] = []; + + for (const cell of cells) { + const cellKey = hexCoordinateKey(cell); + for (let direction = 0; direction < HEX_DIRECTIONS.length; direction += 1) { + const neighbour = { + q: cell.q + HEX_DIRECTIONS[direction].q, + r: cell.r + HEX_DIRECTIONS[direction].r, + }; + const neighbourKey = hexCoordinateKey(neighbour); + if (occupied.has(neighbourKey) && cellKey < neighbourKey) { + edges.push(hexEdge(cell, direction)); + } + } + } + + return edges; +} + +function hexEdge(cell: HexCoordinate, direction: number): HexBoundaryEdge { + const center = { + x: Math.sqrt(3) * (cell.q + cell.r / 2), + y: 1.5 * cell.r, + }; + const corners = Array.from({ length: 6 }, (_, index) => { + const angle = ((-30 + index * 60) * Math.PI) / 180; + return { + x: normalizeHexPointComponent(center.x + Math.cos(angle)), + y: normalizeHexPointComponent(center.y + Math.sin(angle)), + }; + }); + return { + start: corners[(6 - direction) % 6], + end: corners[(7 - direction) % 6], + }; +} + +function normalizeHexPointComponent(value: number): number { + return Math.abs(value) < Number.EPSILON * 8 ? 0 : value; +} + +/** Axial hex distance, useful to consumers for borders and transitions. */ +export function hexDistance(left: HexCoordinate, right: HexCoordinate): number { + const dq = left.q - right.q; + const dr = left.r - right.r; + return Math.max(Math.abs(dq), Math.abs(dr), Math.abs(dq + dr)); +} + +/** True when every cell can be reached from every other through shared edges. */ +export function isContiguousHexTerritory( + cells: readonly HexCoordinate[], +): boolean { + if (cells.length === 0) { + return false; + } + const remaining = new Set(cells.map(hexCoordinateKey)); + const queue: HexCoordinate[] = [cells[0]]; + remaining.delete(hexCoordinateKey(cells[0])); + for (let cursor = 0; cursor < queue.length; cursor += 1) { + for (const neighbour of hexNeighbours(queue[cursor])) { + const key = hexCoordinateKey(neighbour); + if (remaining.delete(key)) { + queue.push(neighbour); + } + } + } + return remaining.size === 0; +} + +export function hexCoordinateKey(coordinate: HexCoordinate): string { + return `${coordinate.q},${coordinate.r}`; +} + +/** Keep only the edge-connected component of valid surviving territories. */ +function connectedRetainedTerritoryIds( + ordered: readonly CommunityComputeLayoutInput[], + previousById: ReadonlyMap, +): Set { + const valid = ordered + .map((deployment) => previousById.get(deployment.id)) + .filter( + (territory): territory is CommunityComputeHexTerritory => + territory !== undefined && + territory.cells.length === + ordered.find((item) => item.id === territory.deploymentId)?.cellCount, + ); + if (valid.length === 0) return new Set(); + + const owner = new Map(); + for (const territory of valid) { + for (const cell of territory.cells) { + const key = hexCoordinateKey(cell); + if (owner.has(key)) return new Set(); + owner.set(key, territory.deploymentId); + } + } + const retained = new Set([valid[0].deploymentId]); + const queue = [valid[0].deploymentId]; + const byId = new Map( + valid.map((territory) => [territory.deploymentId, territory]), + ); + for (let cursor = 0; cursor < queue.length; cursor += 1) { + const territory = byId.get(queue[cursor]); + if (!territory) continue; + for (const cell of territory.cells) { + for (const neighbour of hexNeighbours(cell)) { + const neighbourOwner = owner.get(hexCoordinateKey(neighbour)); + if (neighbourOwner && !retained.has(neighbourOwner)) { + retained.add(neighbourOwner); + queue.push(neighbourOwner); + } + } + } + } + return retained; +} + +function territoryOffsets( + count: HexCellCount, + orientation: number, +): HexCoordinate[] { + const center = { q: 0, r: 0 }; + if (count === 1) { + return [center]; + } + if (count === 2) { + return [center, HEX_DIRECTIONS[orientation]]; + } + if (count === 4) { + return [ + center, + HEX_DIRECTIONS[orientation], + HEX_DIRECTIONS[(orientation + 1) % 6], + HEX_DIRECTIONS[(orientation + 2) % 6], + ]; + } + return [center, ...HEX_DIRECTIONS]; +} + +/** + * Zero-based spiral slot: origin, then complete radius-one, radius-two, … rings. + * Runtime is O(radius), which is O(sqrt(n)); even at dense fixture sizes the + * largest radius remains small. + */ +function balancedSpiralCoordinate(index: number): HexCoordinate { + if (index === 0) { + return { q: 0, r: 0 }; + } + + let radius = 1; + let ringStart = 1; + while (index >= ringStart + 6 * radius) { + ringStart += 6 * radius; + radius += 1; + } + + const ringLength = 6 * radius; + const ringIndex = index - ringStart; + // Multiplication by a number coprime with the ring length is a permutation, + // not sampling: every slot is eventually visited. A step near half a ring + // alternates opposite sides, preventing a partially filled layer from + // producing the one-sided growth of a clockwise spiral. + let step = Math.max(1, Math.floor(ringLength / 2) - 1); + while (greatestCommonDivisor(step, ringLength) !== 1) step -= 1; + const offsetSeed = stableHash(`ring:${radius}`) % ringLength; + let offset = (offsetSeed + ringIndex * step) % ringLength; + let coordinate = { q: -radius, r: radius }; + for (const direction of HEX_DIRECTIONS) { + const steps = Math.min(radius, offset); + coordinate = { + q: coordinate.q + direction.q * steps, + r: coordinate.r + direction.r * steps, + }; + offset -= steps; + if (offset === 0) { + break; + } + } + return coordinate; +} + +function greatestCommonDivisor(left: number, right: number): number { + let a = left; + let b = right; + while (b !== 0) { + const remainder = a % b; + a = b; + b = remainder; + } + return a; +} + +function findBounds( + cells: readonly HexCoordinate[], +): CommunityComputeHexBounds | null { + if (cells.length === 0) { + return null; + } + let minQ = cells[0].q; + let maxQ = cells[0].q; + let minR = cells[0].r; + let maxR = cells[0].r; + for (const cell of cells.slice(1)) { + minQ = Math.min(minQ, cell.q); + maxQ = Math.max(maxQ, cell.q); + minR = Math.min(minR, cell.r); + maxR = Math.max(maxR, cell.r); + } + return { minQ, maxQ, minR, maxR }; +} + +/** FNV-1a over UTF-16 code units; deterministic on every JS runtime. */ +function stableHash(value: string): number { + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} diff --git a/desktop/src/features/mesh-compute/communityComputeMapModel.test.mjs b/desktop/src/features/mesh-compute/communityComputeMapModel.test.mjs new file mode 100644 index 0000000000..e9c3ed32b2 --- /dev/null +++ b/desktop/src/features/mesh-compute/communityComputeMapModel.test.mjs @@ -0,0 +1,206 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + bucketHexCellCount, + capacityGbToHexCellCount, + deriveCommunityComputeMapModel, +} from "./communityComputeMapModel.ts"; +import { + COMMUNITY_COMPUTE_TUTORIAL_FIXTURES, + LARGE_DENSE_COMMUNITY_COMPUTE_FIXTURE, + createLargeDenseSnapshot, +} from "./communityComputeFixtures.ts"; + +function device(overrides = {}) { + return { + deviceId: "device-1", + label: "Studio", + capacityGb: 32, + models: ["gemma"], + state: "serving", + isSelf: false, + memberPubkey: "member-1", + reportedAt: 1_786_032_000, + modelSizeGb: 18, + ...overrides, + }; +} + +function snapshot(overrides = {}) { + return { + sharingDeviceCount: 1, + contributorMemberCount: 1, + sharedCapacityGb: 32, + allocatedCapacityGb: 18, + models: ["gemma"], + devices: [device()], + includesSelf: false, + memberCount: 4, + reason: null, + ...overrides, + }; +} + +test("cell buckets are exactly 1, 2, 4, and 7", () => { + assert.deepEqual( + [-1, 1, 1.1, 2, 2.1, 4, 4.1, 999, Number.NaN].map(bucketHexCellCount), + [1, 1, 2, 2, 4, 4, 7, 7, 1], + ); + assert.deepEqual( + [null, 128, 128.1, 256, 256.1, 512, 512.1, 1000].map( + capacityGbToHexCellCount, + ), + [1, 1, 2, 2, 4, 4, 7, 7], + ); +}); + +test("only ready serving models become deployments", () => { + const model = deriveCommunityComputeMapModel( + snapshot({ + sharingDeviceCount: 1, + devices: [ + device({ deviceId: "a", models: ["gemma", "qwen"] }), + device({ deviceId: "b", state: "loading", models: [] }), + device({ deviceId: "c", state: "standby", models: [] }), + device({ deviceId: "d", state: "consuming", models: [] }), + ], + }), + ); + + assert.equal(model.status, "ready"); + assert.deepEqual( + model.deployments.map((deployment) => deployment.modelId), + ["gemma", "qwen"], + ); + assert.equal(model.kpis.deploymentCount, 2); + assert.equal(model.kpis.contributorMemberCount, 1); + assert.equal(model.health.counts.healthy, 1); + assert.equal(model.health.counts.warming, 1); + assert.equal(model.health.counts.idle, 1); +}); + +test("one territory is produced per model and per device replica", () => { + const model = deriveCommunityComputeMapModel( + snapshot({ + devices: [ + device({ deviceId: "a", models: ["qwen", "gemma"] }), + device({ deviceId: "b", memberPubkey: "member-2", models: ["gemma"] }), + ], + }), + ); + assert.equal(model.deployments.length, 3); + assert.equal( + model.deployments.filter((deployment) => deployment.modelId === "gemma") + .length, + 2, + ); + assert.equal(new Set(model.deployments.map(({ id }) => id)).size, 3); +}); + +test("reported model footprint controls area without fixture overrides", () => { + const model = deriveCommunityComputeMapModel( + snapshot({ devices: [device({ capacityGb: 768, modelSizeGb: 96 })] }), + ); + assert.equal(model.deployments[0].cellCount, 1); + + const large = deriveCommunityComputeMapModel( + snapshot({ devices: [device({ capacityGb: 768, modelSizeGb: 646 })] }), + ); + assert.equal(large.deployments[0].cellCount, 7); +}); + +test("loading, consumer-only, and empty snapshots render no territories", () => { + for (const state of ["loading", "standby", "consuming"]) { + const model = deriveCommunityComputeMapModel( + snapshot({ devices: [device({ state, models: [] })] }), + ); + assert.equal(model.status, "empty"); + assert.equal(model.deployments.length, 0); + } + assert.equal(deriveCommunityComputeMapModel(null).status, "loading"); + assert.equal( + deriveCommunityComputeMapModel( + COMMUNITY_COMPUTE_TUTORIAL_FIXTURES.empty.snapshot, + ).status, + "empty", + ); +}); + +test("projection is deterministic and independent of device/model order", () => { + const devices = [ + device({ deviceId: "z", label: "Zulu", models: ["qwen", "gemma"] }), + device({ deviceId: "a", label: "Alpha", models: ["mistral"] }), + ]; + const forward = deriveCommunityComputeMapModel(snapshot({ devices })); + const reverse = deriveCommunityComputeMapModel( + snapshot({ + devices: [...devices] + .reverse() + .map((item) => ({ ...item, models: [...item.models].reverse() })), + }), + ); + assert.deepEqual(forward, reverse); +}); + +test("tutorial fixtures use only production fields and production sizing", () => { + assert.deepEqual(Object.keys(COMMUNITY_COMPUTE_TUTORIAL_FIXTURES).sort(), [ + "empty", + "large-dense-500", + "single-device", + "small-company", + ]); + const allowed = new Set([ + "deviceId", + "label", + "capacityGb", + "models", + "state", + "isSelf", + "memberPubkey", + "reportedAt", + "modelSizeGb", + ]); + for (const fixture of Object.values(COMMUNITY_COMPUTE_TUTORIAL_FIXTURES)) { + for (const item of fixture.snapshot.devices) { + assert.deepEqual( + Object.keys(item).filter((key) => !allowed.has(key)), + [], + ); + } + } + const small = deriveCommunityComputeMapModel( + COMMUNITY_COMPUTE_TUTORIAL_FIXTURES["small-company"].snapshot, + ); + assert.equal(small.deployments.length, 12); + assert.equal( + small.deployments.filter(({ deviceLabel }) => + deviceLabel.startsWith("Team MacBook"), + ).length, + 10, + ); + assert.equal( + small.deployments.filter(({ cellCount }) => cellCount === 1).length, + 10, + ); + assert.equal( + small.deployments.filter(({ cellCount }) => cellCount === 7).length, + 2, + ); +}); + +test("large dense fixture is deterministic and excludes non-serving rows", () => { + const fixture = LARGE_DENSE_COMMUNITY_COMPUTE_FIXTURE.snapshot; + assert.deepEqual(fixture, createLargeDenseSnapshot(500)); + assert.equal(fixture.devices.length, 500); + const servingCount = fixture.devices.filter( + (item) => item.state === "serving" && item.models.length > 0, + ).length; + const model = deriveCommunityComputeMapModel(fixture); + assert.equal(model.deployments.length, servingCount); + assert.equal(model.kpis.deploymentCount, servingCount); + assert.equal( + new Set(model.deployments.map(({ id }) => id)).size, + servingCount, + ); +}); diff --git a/desktop/src/features/mesh-compute/communityComputeMapModel.ts b/desktop/src/features/mesh-compute/communityComputeMapModel.ts new file mode 100644 index 0000000000..663b957d5f --- /dev/null +++ b/desktop/src/features/mesh-compute/communityComputeMapModel.ts @@ -0,0 +1,365 @@ +import type { + MeshDeviceState, + MeshSnapshot, + MeshSnapshotDevice, +} from "@/shared/api/tauriMesh"; + +/** The only territory sizes rendered by the community compute map. */ +export const HEX_CELL_BUCKETS = [1, 2, 4, 7] as const; + +export type HexCellCount = (typeof HEX_CELL_BUCKETS)[number]; + +/** + * The Community view intentionally consumes only fields in the production + * snapshot contract. Tutorial fixtures use this same shape so examples cannot + * quietly grow capabilities that live Compute does not have. + */ +export type CommunityComputeDeviceInput = MeshSnapshotDevice; + +/** A readonly, structurally compatible form of the current mesh snapshot. */ +export type CommunityComputeSnapshotInput< + TDevice extends MeshSnapshotDevice = CommunityComputeDeviceInput, +> = Omit & { + readonly devices: readonly TDevice[]; +}; + +export type CommunityComputeDeploymentHealth = + | "healthy" + | "warming" + | "idle" + | "degraded" + | "offline" + | "unknown"; + +export type CommunityComputeMapHealth = + | "healthy" + | "attention" + | "critical" + | "empty" + | "unknown"; + +export type CommunityComputeDeployment< + TDevice extends MeshSnapshotDevice = CommunityComputeDeviceInput, +> = { + /** Stable map identity. Never derived from the device's array position. */ + id: string; + /** Model shown as the territory's primary identity. */ + label: string; + modelId: string; + deviceLabel: string; + capacityGb: number | null; + models: readonly string[]; + state: MeshDeviceState; + isSelf: boolean; + health: CommunityComputeDeploymentHealth; + healthReason: string | null; + cellCount: HexCellCount; + /** Preserves additive backend data for a future detail projection. */ + source: Readonly; +}; + +export type CommunityComputeKpis = { + memberCount: number; + contributorMemberCount: number; + deploymentCount: number; + activeDeploymentCount: number; + sharedCapacityGb: number | null; + allocatedCapacityGb: number | null; + allocatedPercent: number | null; + modelCount: number; +}; + +export type CommunityComputeHealthSummary = { + status: CommunityComputeMapHealth; + label: string; + reason: string | null; + counts: Record; +}; + +export type CommunityComputeMapModel< + TDevice extends MeshSnapshotDevice = CommunityComputeDeviceInput, +> = { + status: "loading" | "ready" | "empty"; + kpis: CommunityComputeKpis; + health: CommunityComputeHealthSummary; + deployments: readonly CommunityComputeDeployment[]; +}; + +/** + * Round a desired territory footprint up to one of the four supported shapes. + * Invalid and non-positive weights still get one cell so a deployment cannot + * disappear from the map. + */ +export function bucketHexCellCount( + weight: number | null | undefined, +): HexCellCount { + if (weight === null || weight === undefined || !Number.isFinite(weight)) { + return 1; + } + const normalized = Math.max(1, Math.ceil(weight)); + return HEX_CELL_BUCKETS.find((bucket) => normalized <= bucket) ?? 7; +} + +/** + * Turn model memory into a deliberately coarse footprint. + * + * A 128 GB Mac is the common mesh workhorse, so every model that fits on one is + * one cell. Multi-cell territories are reserved for genuinely multi-GPU scale + * deployments; this avoids making ordinary local models look fragmented. + */ +export function capacityGbToHexCellCount( + capacityGb: number | null | undefined, +): HexCellCount { + if (capacityGb === null || capacityGb === undefined || capacityGb <= 128) { + return 1; + } + if (capacityGb <= 256) { + return 2; + } + if (capacityGb <= 512) { + return 4; + } + return 7; +} + +const EMPTY_HEALTH_COUNTS: Record = { + healthy: 0, + warming: 0, + idle: 0, + degraded: 0, + offline: 0, + unknown: 0, +}; + +/** Project a backend snapshot into the complete, render-agnostic map model. */ +export function deriveCommunityComputeMapModel< + TDevice extends MeshSnapshotDevice = CommunityComputeDeviceInput, +>( + snapshot: CommunityComputeSnapshotInput | null, +): CommunityComputeMapModel { + if (snapshot === null) { + return { + status: "loading", + kpis: { + memberCount: 0, + contributorMemberCount: 0, + deploymentCount: 0, + activeDeploymentCount: 0, + sharedCapacityGb: null, + allocatedCapacityGb: null, + allocatedPercent: null, + modelCount: 0, + }, + health: { + status: "unknown", + label: "Checking community compute", + reason: null, + counts: { ...EMPTY_HEALTH_COUNTS }, + }, + deployments: [], + }; + } + + const deployments = projectDeployments(snapshot.devices); + const healthStates = snapshot.devices + .filter((device) => device.state !== "consuming") + .map((device) => healthFromState(device.state)); + const counts = countHealth(healthStates); + const models = new Set(snapshot.models); + for (const deployment of deployments) { + for (const model of deployment.models) { + models.add(model); + } + } + + const contributorMemberCount = + snapshot.contributorMemberCount ?? + new Set( + deployments + .filter((deployment) => deployment.state === "serving") + .map( + (deployment) => + deployment.source.memberPubkey?.trim() || deployment.id, + ), + ).size; + const sharedCapacityGb = finiteNonNegative(snapshot.sharedCapacityGb); + const reportedAllocated = finiteNonNegative(snapshot.allocatedCapacityGb); + const servingDeployments = deployments.filter( + (deployment) => deployment.state === "serving", + ); + const inferredAllocations = servingDeployments.map((deployment) => + finiteNonNegative(deployment.source.modelSizeGb), + ); + const allocatedCapacityGb = + reportedAllocated ?? + (inferredAllocations.length > 0 && + inferredAllocations.every((value) => value !== null) + ? inferredAllocations.reduce((total, value) => total + (value ?? 0), 0) + : null); + + return { + status: deployments.length === 0 ? "empty" : "ready", + kpis: { + memberCount: nonNegativeInteger(snapshot.memberCount), + contributorMemberCount, + deploymentCount: deployments.length, + activeDeploymentCount: counts.healthy + counts.warming, + sharedCapacityGb, + allocatedCapacityGb, + allocatedPercent: + allocatedCapacityGb !== null && + sharedCapacityGb !== null && + sharedCapacityGb > 0 + ? Math.min(100, (allocatedCapacityGb / sharedCapacityGb) * 100) + : null, + modelCount: models.size, + }, + health: summarizeHealth(counts, healthStates, snapshot.reason), + deployments, + }; +} + +function projectDeployments( + devices: readonly TDevice[], +): CommunityComputeDeployment[] { + const canonical = devices + .filter((device) => device.state === "serving" && device.models.length > 0) + .flatMap((device) => + [...new Set(device.models)].map((model) => ({ + device: { ...device, models: [model] } as TDevice, + identity: deploymentIdentity(device, model), + })), + ) + .sort( + (left, right) => + left.identity.localeCompare(right.identity) || + deviceFingerprint(left.device).localeCompare( + deviceFingerprint(right.device), + ), + ); + const occurrences = new Map(); + + return canonical.map(({ device, identity }) => { + const occurrence = occurrences.get(identity) ?? 0; + occurrences.set(identity, occurrence + 1); + const models = [...new Set(device.models)].sort(); + return { + id: occurrence === 0 ? identity : `${identity}#${occurrence + 1}`, + label: models[0] ?? device.label, + modelId: models[0] ?? "Model not reported", + deviceLabel: device.label, + capacityGb: finiteNonNegative(device.capacityGb), + models, + state: device.state, + isSelf: device.isSelf, + health: healthFromState(device.state), + healthReason: null, + cellCount: capacityGbToHexCellCount( + device.modelSizeGb ?? device.capacityGb, + ), + source: device, + }; + }); +} + +function deploymentIdentity( + device: MeshSnapshotDevice, + modelId: string, +): string { + const deviceId = cleanOptionalText(device.deviceId); + const model = encodeURIComponent(modelId); + return deviceId === null + ? `derived:${encodeURIComponent(deviceFingerprint(device))}:${model}` + : `deployment:${encodeURIComponent(deviceId)}:${model}`; +} + +function deviceFingerprint(device: MeshSnapshotDevice): string { + return JSON.stringify([ + device.label, + device.capacityGb, + [...device.models].sort(), + device.state, + device.isSelf, + ]); +} + +function healthFromState( + state: MeshDeviceState, +): CommunityComputeDeploymentHealth { + switch (state) { + case "serving": + case "consuming": + return "healthy"; + case "loading": + return "warming"; + case "standby": + return "idle"; + } +} + +function countHealth( + states: readonly CommunityComputeDeploymentHealth[], +): Record { + const counts = { ...EMPTY_HEALTH_COUNTS }; + for (const state of states) { + counts[state] += 1; + } + return counts; +} + +function summarizeHealth( + counts: Record, + states: readonly CommunityComputeDeploymentHealth[], + snapshotReason: string | null, +): CommunityComputeHealthSummary { + if (states.length === 0) { + return { + status: "empty", + label: "No deployments", + reason: cleanOptionalText(snapshotReason), + counts, + }; + } + + const unavailable = counts.offline + counts.degraded; + const reason = null; + + if (counts.offline === states.length) { + return { status: "critical", label: "Compute unavailable", reason, counts }; + } + if (unavailable > 0) { + return { + status: "attention", + label: `${unavailable} deployment${unavailable === 1 ? "" : "s"} need attention`, + reason, + counts, + }; + } + if (counts.unknown === states.length) { + return { status: "unknown", label: "Health unknown", reason: null, counts }; + } + return { + status: "healthy", + label: "No reported issues", + reason: null, + counts, + }; +} + +function finiteNonNegative(value: number | null | undefined): number | null { + return value !== null && + value !== undefined && + Number.isFinite(value) && + value >= 0 + ? value + : null; +} + +function nonNegativeInteger(value: number): number { + return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; +} + +function cleanOptionalText(value: string | null | undefined): string | null { + const cleaned = value?.trim(); + return cleaned ? cleaned : null; +} diff --git a/desktop/src/features/mesh-compute/hooks/useMeshNodeStatus.ts b/desktop/src/features/mesh-compute/hooks/useMeshNodeStatus.ts index c9194a0e05..2bc632d84b 100644 --- a/desktop/src/features/mesh-compute/hooks/useMeshNodeStatus.ts +++ b/desktop/src/features/mesh-compute/hooks/useMeshNodeStatus.ts @@ -17,42 +17,54 @@ export function useMeshNodeStatus(): { status: MeshNodeStatus | null; error: string | null; refresh: () => void; + update: (status: MeshNodeStatus) => void; } { const [status, setStatus] = React.useState(null); const [error, setError] = React.useState(null); + const requestSequence = React.useRef(0); const fetchOnce = React.useCallback(() => { - let cancelled = false; - (async () => { + const sequence = ++requestSequence.current; + void (async () => { try { const value = await meshNodeStatus(); - if (!cancelled) { + if (sequence === requestSequence.current) { setStatus(value); setError(null); } } catch (err) { - if (!cancelled) { + if (sequence === requestSequence.current) { setError(err instanceof Error ? err.message : String(err)); } } })(); - return () => { - cancelled = true; - }; }, []); - React.useEffect(() => fetchOnce(), [fetchOnce]); + const update = React.useCallback((value: MeshNodeStatus) => { + // Invalidate any slower request started before this authoritative command + // result so it cannot restore the preceding lifecycle state. + requestSequence.current += 1; + setStatus(value); + setError(null); + }, []); + + React.useEffect(() => { + fetchOnce(); + }, [fetchOnce]); // Fast poll while in a transitioning state; slow poll while steady or off. React.useEffect(() => { const transitioning = status?.state === "starting" || status?.state === "stopping"; - const interval = transitioning ? 750 : 4000; + // Keep reconciling aggressively while a start command is still waiting on + // model readiness. The command can take minutes and its eventual response + // is not the only signal that the runtime crossed to running. + const interval = transitioning ? 500 : 4000; const handle = window.setInterval(() => { fetchOnce(); }, interval); return () => window.clearInterval(handle); }, [status?.state, fetchOnce]); - return { status, error, refresh: fetchOnce }; + return { status, error, refresh: fetchOnce, update }; } diff --git a/desktop/src/features/mesh-compute/hooks/useMeshSnapshot.ts b/desktop/src/features/mesh-compute/hooks/useMeshSnapshot.ts new file mode 100644 index 0000000000..85de3d794d --- /dev/null +++ b/desktop/src/features/mesh-compute/hooks/useMeshSnapshot.ts @@ -0,0 +1,62 @@ +import * as React from "react"; + +import { meshSnapshot } from "@/shared/api/tauriMesh"; +import type { MeshSnapshot } from "@/shared/api/tauriMesh"; + +/** + * Poll the community shared-compute snapshot. + * + * Deliberately slow compared to `useMeshNodeStatus` (750ms/4000ms): that hook + * watches a LOCAL lifecycle that must feel responsive, while this one reads + * RELAY state that only changes as fast as the 45s member heartbeat, with a + * 120s freshness window. Polling faster would just add relay queries without + * adding information. + * + * An empty mesh is not an error — the snapshot carries its own `reason`. `error` + * here means the query itself failed (offline relay, stub build). + */ +const SNAPSHOT_POLL_INTERVAL_MS = 30_000; + +export function useMeshSnapshot(options?: { refreshKey?: number }): { + snapshot: MeshSnapshot | null; + error: string | null; + refresh: () => void; +} { + const [snapshot, setSnapshot] = React.useState(null); + const [error, setError] = React.useState(null); + const refreshKey = options?.refreshKey ?? 0; + + // Guards against a slow in-flight response overwriting a newer one. + const requestSeq = React.useRef(0); + + const fetchOnce = React.useCallback(() => { + const seq = ++requestSeq.current; + (async () => { + try { + const value = await meshSnapshot(); + if (seq === requestSeq.current) { + setSnapshot(value); + setError(null); + } + } catch (err) { + if (seq === requestSeq.current) { + setError(err instanceof Error ? err.message : String(err)); + } + } + })(); + }, []); + + // Re-fetch immediately when the local node transitions (refreshKey), so + // turning sharing on updates the community view without waiting for a poll. + // biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey is an intentional trigger, not a value read in the body — bumping it re-queries the community snapshot right after a local node transition instead of waiting out the 30s poll + React.useEffect(() => { + fetchOnce(); + }, [fetchOnce, refreshKey]); + + React.useEffect(() => { + const handle = window.setInterval(fetchOnce, SNAPSHOT_POLL_INTERVAL_MS); + return () => window.clearInterval(handle); + }, [fetchOnce]); + + return { snapshot, error, refresh: fetchOnce }; +} diff --git a/desktop/src/features/mesh-compute/ui/CommunityComputeTerritoryMap.tsx b/desktop/src/features/mesh-compute/ui/CommunityComputeTerritoryMap.tsx new file mode 100644 index 0000000000..91dd44d2ab --- /dev/null +++ b/desktop/src/features/mesh-compute/ui/CommunityComputeTerritoryMap.tsx @@ -0,0 +1,342 @@ +import * as React from "react"; + +import { cn } from "@/shared/lib/cn"; +import { + hexTerritoryBoundaryEdges, + layoutCommunityComputeHexTerritories, + type CommunityComputeHexCell, +} from "../communityComputeHexLayout"; +import type { + CommunityComputeDeployment, + CommunityComputeMapModel, +} from "../communityComputeMapModel"; + +export type CommunityComputeTerritoryMapProps = { + model: CommunityComputeMapModel; + className?: string; +}; + +type Point = { x: number; y: number }; + +type ViewBox = { + x: number; + y: number; + width: number; + height: number; +}; + +// Deliberately overlap polygons, then trim their outside with the territory +// edge. This avoids SVG antialias seams inside one model footprint. +const FUSED_TILE_RADIUS = 1.025; +const TILE_EDGE_WIDTH = 0.1; +const SQRT_THREE = Math.sqrt(3); + +/** + * Responsive SVG territory map. Each deployment owns one stable, contiguous + * cluster of discrete hexes; health changes never move that cluster. + */ +export function CommunityComputeTerritoryMap({ + model, + className, +}: CommunityComputeTerritoryMapProps) { + const deploymentById = React.useMemo( + () => + new Map( + model.deployments.map((deployment) => [deployment.id, deployment]), + ), + [model.deployments], + ); + const previousLayoutRef = React.useRef | null>(null); + const layout = React.useMemo( + () => + layoutCommunityComputeHexTerritories( + model.deployments.map(({ id, cellCount }) => ({ id, cellCount })), + { previous: previousLayoutRef.current }, + ), + [model.deployments], + ); + React.useEffect(() => { + previousLayoutRef.current = layout; + }, [layout]); + const viewBox = computeViewBox(layout.cells); + const [hoveredId, setHoveredId] = React.useState(null); + const lowerTimerRef = React.useRef | null>( + null, + ); + const hoveredDeployment = hoveredId + ? (deploymentById.get(hoveredId) ?? null) + : null; + const isDenseMap = model.deployments.length >= 50; + + React.useEffect( + () => () => { + if (lowerTimerRef.current) clearTimeout(lowerTimerRef.current); + }, + [], + ); + + function raiseTerritory(deploymentId: string, territoryElement: SVGGElement) { + if (lowerTimerRef.current) clearTimeout(lowerTimerRef.current); + // SVG paints in DOM order. Move the existing node instead of rebuilding + // children so hover stays smooth as territories scale. + territoryElement.parentNode?.appendChild(territoryElement); + setHoveredId(deploymentId); + } + + function lowerTerritory() { + if (lowerTimerRef.current) clearTimeout(lowerTimerRef.current); + // Let the detail card linger briefly while the territory settles. + lowerTimerRef.current = setTimeout(() => setHoveredId(null), 280); + } + + if (model.status === "loading") { + return ( +
+
+

+ Mapping community compute… +

+
+ ); + } + + if (model.status === "empty" || viewBox === null) { + return ( +
+ +

No compute territories yet

+

+ Shared compute appears here when members make it available to the + community. +

+
+ ); + } + + return ( +
+
+ + {layout.territories.map((territory) => { + const deployment = deploymentById.get(territory.deploymentId); + if (!deployment) return null; + return ( + // SVG groups have no semantic element for hover/focus detail disclosure. + // biome-ignore lint/a11y/noStaticElementInteractions: keyboard focus mirrors hover without making the territory an action. + + raiseTerritory(deployment.id, event.currentTarget) + } + onMouseEnter={(event) => + raiseTerritory(deployment.id, event.currentTarget) + } + onMouseLeave={lowerTerritory} + style={territoryHoverStyle( + territory.cells, + viewBox, + isDenseMap, + )} + tabIndex={0} + > + {territoryAccessibleLabel(deployment)} + + + ); + })} + + {hoveredDeployment ? ( +
+

+ {shortModelName(hoveredDeployment.modelId)} +

+

+ {hoveredDeployment.deviceLabel} ·{" "} + {formatCapacity(hoveredDeployment.capacityGb)} +

+

+ Available to the community +

+
+ ) : null} +
+
+ ); +} + +function TerritoryShape({ + cells, +}: { + cells: readonly CommunityComputeHexCell[]; +}) { + const boundary = hexTerritoryBoundaryEdges(cells); + return ( + + {cells.map((cell) => { + const center = axialToPoint(cell.q, cell.r); + return ( + + ); + })} + + + ); +} + +function territoryHoverStyle( + cells: readonly CommunityComputeHexCell[], + viewBox: ViewBox, + dense: boolean, +): React.CSSProperties { + const centers = cells.map((cell) => axialToPoint(cell.q, cell.r)); + const minX = Math.min(...centers.map(({ x }) => x)) - 1; + const maxX = Math.max(...centers.map(({ x }) => x)) + 1; + const minY = Math.min(...centers.map(({ y }) => y)) - 1; + const maxY = Math.max(...centers.map(({ y }) => y)) + 1; + const territorySpan = Math.max(maxX - minX, maxY - minY); + const mapSpan = Math.min(viewBox.width, viewBox.height); + // Normalize every hovered territory toward the same visual footprint. Dense + // maps therefore expand dramatically while already-large sparse territories + // receive only a restrained lift. + const targetSpan = mapSpan * (dense ? 0.18 : 0.16); + const scale = Math.max(1.06, Math.min(8, targetSpan / territorySpan)); + return { "--mesh-hover-scale": scale } as React.CSSProperties; +} + +function edgesToPath( + edges: ReturnType, +): string { + return edges + .map( + (edge) => + `M ${edge.start.x} ${edge.start.y} L ${edge.end.x} ${edge.end.y}`, + ) + .join(" "); +} + +function EmptyHexes() { + const centers = [ + { x: 2, y: 1.75 }, + { x: 3.58, y: 2.65 }, + { x: 2, y: 3.55 }, + { x: 0.42, y: 2.65 }, + ]; + return ( + + ); +} + +function axialToPoint(q: number, r: number): Point { + return { x: SQRT_THREE * (q + r / 2), y: 1.5 * r }; +} + +function hexPoints(center: Point, radius: number): string { + return Array.from({ length: 6 }, (_, index) => { + const angle = ((60 * index - 30) * Math.PI) / 180; + return `${center.x + radius * Math.cos(angle)},${center.y + radius * Math.sin(angle)}`; + }).join(" "); +} + +function computeViewBox( + cells: readonly CommunityComputeHexCell[], +): ViewBox | null { + if (cells.length === 0) return null; + const centers = cells.map((cell) => axialToPoint(cell.q, cell.r)); + const minX = Math.min(...centers.map(({ x }) => x)) - 1.4; + const maxX = Math.max(...centers.map(({ x }) => x)) + 1.4; + const minY = Math.min(...centers.map(({ y }) => y)) - 1.4; + const maxY = Math.max(...centers.map(({ y }) => y)) + 1.4; + return { + x: minX, + y: minY, + width: Math.max(2.8, maxX - minX), + height: Math.max(2.8, maxY - minY), + }; +} + +function shortModelName(modelId: string): string { + const tail = modelId.split("/").at(-1) ?? modelId; + return tail.length > 34 ? `${tail.slice(0, 31)}…` : tail; +} + +function territoryAccessibleLabel( + deployment: CommunityComputeDeployment, +): string { + const capacity = formatCapacity(deployment.capacityGb); + return `${deployment.modelId} on ${deployment.deviceLabel}, ${capacity}`; +} + +function formatCapacity(value: number | null): string { + return value === null + ? "Capacity not reported" + : `${Math.round(value)} GB shared`; +} + +function testId(value: string): string { + return encodeURIComponent(value).replaceAll("%", "-"); +} diff --git a/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx b/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx index c27c5eef3a..bb5590b3e2 100644 --- a/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx +++ b/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx @@ -1,6 +1,5 @@ import * as React from "react"; -import { ChevronDown } from "lucide-react"; -import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { ChevronDown, Hexagon, Sparkles } from "lucide-react"; import { Switch } from "@/shared/ui/switch"; import { cn } from "@/shared/lib/cn"; @@ -29,6 +28,12 @@ import type { } from "@/shared/api/tauriMesh"; import { SettingsOptionGroup } from "@/features/settings/ui/SettingsOptionGroup"; import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; +import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { Button } from "@/shared/ui/button"; +import { useMeshSnapshot } from "../hooks/useMeshSnapshot"; +import { deriveCommunityComputeMapModel } from "../communityComputeMapModel"; +import { CommunityComputeTerritoryMap } from "./CommunityComputeTerritoryMap"; import { classifyModelRef } from "../classifyModelRef"; import { downloadPercent, @@ -52,11 +57,6 @@ const MESH_SELECT_TRIGGER_CLASS = cn( "h-11 px-3 py-2 leading-6 hover:bg-muted/40 focus:bg-muted/40 [&>svg]:text-muted-foreground/60", ); -const SHARE_COMPUTE_REVEAL_TRANSITION = { - duration: 0.22, - ease: [0.23, 1, 0.32, 1], -} as const; - function readDraft(key: string): string { try { return window.localStorage.getItem(key) ?? ""; @@ -85,8 +85,11 @@ function writeDraft(key: string, value: string): void { * exposing implementation protocols or raw mesh controls. */ export function MeshComputeSettingsCard() { - const shouldReduceMotion = useReducedMotion(); - const { status, error, refresh } = useMeshNodeStatus(); + const { activeCommunity } = useCommunities(); + const communityName = activeCommunity?.name ?? "this community"; + const { status, error, refresh, update } = useMeshNodeStatus(); + const [snapshotRefreshKey, setSnapshotRefreshKey] = React.useState(0); + const { snapshot } = useMeshSnapshot({ refreshKey: snapshotRefreshKey }); const [installedModels, setInstalledModels] = React.useState< MeshModelOption[] >([]); @@ -187,6 +190,17 @@ export function MeshComputeSettingsCard() { const refClass = classifyModelRef(modelInput); const canStart = refClass.kind !== "unknown" && !actionInFlight; const showSharingControls = isSharing || pendingAction === "start"; + // Live runtime state wins over the still-pending command promise. Starting a + // node waits on an additional inference-readiness probe, so the command can + // remain in flight after status already proves this machine is running. Do + // not leave the visual state spinning during that window. + const isSuccessfullySharing = + status?.mode === "serve" && status.state === "running"; + const isTransitioning = + !isSuccessfullySharing && + (pendingAction !== null || + status?.state === "starting" || + status?.state === "stopping"); async function handleToggle(next: boolean) { // Never let the Share switch tear down a consume session. The switch is @@ -203,7 +217,7 @@ export function MeshComputeSettingsCard() { if (next) { const maxVram = maxVramGb.trim() === "" ? undefined : Number.parseFloat(maxVramGb); - await meshStartNode({ + const nextStatus = await meshStartNode({ mode: "serve", modelId: modelInput.trim() || undefined, maxVramGb: @@ -211,10 +225,13 @@ export function MeshComputeSettingsCard() { ? maxVram : undefined, }); + update(nextStatus); } else { - await meshStopNode(); + const nextStatus = await meshStopNode(); + update(nextStatus); } refresh(); + setSnapshotRefreshKey((current) => current + 1); } catch (err) { setActionError(err instanceof Error ? err.message : String(err)); } finally { @@ -227,8 +244,8 @@ export function MeshComputeSettingsCard() { return (
{error ? ( @@ -248,20 +265,68 @@ export function MeshComputeSettingsCard() {
-
- - {!isSharing ? ( - - ) : null} + {isSuccessfullySharing ? ( + + + + ) : null} + {isTransitioning ? ( + + Preparing shared compute + + + + ) : ( + + )} + +
+ + {!isSuccessfullySharing ? ( + + ) : ( +

+ This machine is actively sharing compute on {communityName} + ’s mesh. +

+ )} +
+ {showSharingControls ? ( +
+

Status

+
+ + {servingIndicator.show ? ( +

+ {servingIndicator.label} + {servingIndicator.detail + ? ` · ${servingIndicator.detail}` + : null} +

+ ) : null} +
+
+ ) : null} {status?.consoleUrl ? (

Debug console:{" "} @@ -347,59 +439,47 @@ export function MeshComputeSettingsCard() { ) : null}

- - {showSharingControls ? ( - -
-

Status

-
- - {servingIndicator.show ? ( -

- {servingIndicator.label} - {servingIndicator.detail ? ( - - {" "} - · {servingIndicator.detail} - - ) : null} -

- ) : null} -
-
-
- ) : null} -
+ {(snapshot?.contributorMemberCount ?? 0) >= 6 ? ( +
+
+

Community mesh

+

+ {snapshot?.contributorMemberCount} people are contributing + compute. +

+
+ +
+ ) : null}
+ +
+
+
+

+ Put the community mesh to work +

+

+ Anyone can use compute shared by the community—even if this + machine can’t contribute. Name your agent and customize its + instructions; we’ll handle the compute setup. +

+
+ +
+
); } @@ -496,7 +576,9 @@ function MeshModelPicker({ seen.add(entry.name); return { disabled: entry.fit === "too_large", - label: , + label: ( + + ), value: entry.name, }; }); @@ -523,7 +605,7 @@ function MeshModelPicker({ ...localOptions, { label: "Custom model…", value: CUSTOM_MODEL_DROPDOWN_VALUE }, ]; - }, [catalog?.entries, installedModels]); + }, [catalog?.entries, catalog?.gpuName, installedModels]); const knownModel = options.some((option) => option.value === model.trim()); const showCustomModelInput = isCustomModelEditing || (model.trim().length > 0 && !knownModel); @@ -578,16 +660,19 @@ function MeshModelPicker({ className="text-sm font-normal text-muted-foreground/70" data-settings-subcopy > - {catalog - ? `Recommended for this machine${catalog.gpuName ? ` (${catalog.gpuName}, ${catalog.vramDisplay} AI memory)` : ""}.` - : "Choose a model or enter a model reference or local file."}{" "} - Buzz downloads remote models when sharing starts. + Buzz downloads the selected model when sharing starts.

); } -function MeshModelOptionLabel({ entry }: { entry: MeshCatalogEntry }) { +function MeshModelOptionLabel({ + entry, + hardware, +}: { + entry: MeshCatalogEntry; + hardware?: string | null; +}) { return (
{entry.name} @@ -596,8 +681,8 @@ function MeshModelOptionLabel({ entry }: { entry: MeshCatalogEntry }) { {FIT_LABEL[entry.fit]} {entry.recommended ? ( - - Recommended + + Recommended for {hardware ?? "this machine"} ) : null} {entry.installed ? ( diff --git a/desktop/src/shared/api/tauriMesh.ts b/desktop/src/shared/api/tauriMesh.ts index 8a0153123f..1890e76650 100644 --- a/desktop/src/shared/api/tauriMesh.ts +++ b/desktop/src/shared/api/tauriMesh.ts @@ -112,3 +112,35 @@ export type MeshModelCatalog = { export async function meshModelCatalog(): Promise { return await invokeTauri("mesh_model_catalog"); } + +export type MeshDeviceState = "serving" | "loading" | "standby" | "consuming"; + +export type MeshSnapshotDevice = { + deviceId: string | null; + label: string; + capacityGb: number | null; + models: string[]; + state: MeshDeviceState; + isSelf: boolean; + memberPubkey?: string | null; + reportedAt?: number | null; + modelSizeGb?: number | null; +}; + +export type MeshSnapshot = { + sharingDeviceCount: number; + contributorMemberCount?: number; + sharedCapacityGb: number | null; + allocatedCapacityGb?: number | null; + models: string[]; + devices: MeshSnapshotDevice[]; + includesSelf: boolean; + observedAt?: number; + freshnessSeconds?: number; + memberCount: number; + reason: string | null; +}; + +export async function meshSnapshot(): Promise { + return await invokeTauri("mesh_snapshot"); +} diff --git a/desktop/src/shared/styles/globals/animations.css b/desktop/src/shared/styles/globals/animations.css index 5a0723cdaa..bc248a0523 100644 --- a/desktop/src/shared/styles/globals/animations.css +++ b/desktop/src/shared/styles/globals/animations.css @@ -930,3 +930,14 @@ animation: none; } } + +/* The shape stays fixed while a short dark stroke travels around its edge. */ +@keyframes mesh-compute-hex-trace { + to { + stroke-dashoffset: -100; + } +} + +.mesh-compute-hex-trace { + animation: mesh-compute-hex-trace 1.1s linear infinite; +} diff --git a/desktop/tests/e2e/mesh-compute.spec.ts b/desktop/tests/e2e/mesh-compute.spec.ts index c55b9db5d9..e7dd937d4b 100644 --- a/desktop/tests/e2e/mesh-compute.spec.ts +++ b/desktop/tests/e2e/mesh-compute.spec.ts @@ -26,9 +26,6 @@ test("Share compute chooses a model before sharing", async ({ page }) => { const model = page.getByTestId("mesh-share-compute-model"); await expect(card).not.toContainText("Not sharing right now"); - await expect( - page.getByTestId("mesh-share-compute-options-motion"), - ).toHaveCount(0); await expect( page.getByTestId("mesh-share-compute-sharing-status"), ).toHaveCount(0); @@ -39,17 +36,20 @@ test("Share compute chooses a model before sharing", async ({ page }) => { await page.getByLabel("Custom model reference").fill(modelRef); await toggle.click(); - await expect( - page.getByTestId("mesh-share-compute-options-motion"), - ).toBeVisible(); + await expect(toggle).toBeChecked(); + await expect(card).toContainText("You’re sharing compute"); + await expect(card).toContainText("This machine is actively sharing compute"); + await expect(model).toBeVisible(); + await expect(card).toContainText( + "Buzz downloads the selected model when sharing starts", + ); + + // Runtime/model details are intentionally secondary to the successful + // sharing state and remain available under Advanced. + await page.getByTestId("mesh-share-compute-advanced-toggle").click(); await expect( page.getByTestId("mesh-share-compute-sharing-status"), ).toBeVisible(); - await expect(model).toBeVisible(); - await expect(card).toContainText( - "Buzz downloads remote models when sharing starts", - ); - await expect(toggle).toBeChecked(); await expect( page.getByTestId("mesh-share-compute-sharing-status"), ).toContainText("SmolLM2 135M with relay members"); @@ -74,9 +74,6 @@ test("Share compute chooses a model before sharing", async ({ page }) => { await toggle.click(); await expect(toggle).not.toBeChecked(); await expect(card).not.toContainText("Not sharing right now"); - await expect( - page.getByTestId("mesh-share-compute-options-motion"), - ).toHaveCount(0); await expect( page.getByTestId("mesh-share-compute-sharing-status"), ).toHaveCount(0); @@ -123,9 +120,6 @@ test("a consuming client can switch to sharing its saved local model", async ({ ); await expect(card).toContainText("Buzz may briefly restart"); await expect(toggle).not.toBeChecked(); - await expect( - page.getByTestId("mesh-share-compute-options-motion"), - ).toHaveCount(0); await expect(toggle).toBeEnabled(); const customModel = page.getByLabel("Custom model reference"); await expect(customModel).toHaveValue(localModel);