mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Sprout × mesh-llm: in-process mesh node (serve/consume) + relay admission (#798)
This commit is contained in:
45
.github/workflows/ci.yml
vendored
45
.github/workflows/ci.yml
vendored
@@ -479,7 +479,52 @@ jobs:
|
||||
touch "desktop/src-tauri/binaries/sprout-dev-mcp-$TARGET"
|
||||
touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET"
|
||||
touch "desktop/src-tauri/binaries/sprout-$TARGET"
|
||||
# Mesh rev is derived from Cargo.lock so a dependency bump needs no
|
||||
# lockstep edit here; the cache key tracks it automatically.
|
||||
- name: Resolve mesh-llm rev
|
||||
id: mesh_rev
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REV=$(grep -oE 'mesh-llm\.git\?rev=[0-9a-f]{40}' Cargo.lock | head -1 | grep -oE '[0-9a-f]{40}')
|
||||
[[ -n "$REV" ]] || { echo "::error::could not resolve mesh-llm rev from Cargo.lock"; exit 1; }
|
||||
echo "rev=$REV" >> "$GITHUB_OUTPUT"
|
||||
echo "short=${REV:0:7}" >> "$GITHUB_OUTPUT"
|
||||
- name: Restore mesh llama build cache
|
||||
id: llama_cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: ${{ github.workspace }}/.cache/mesh-llama
|
||||
key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }}
|
||||
- name: Build mesh llama native libraries
|
||||
if: steps.llama_cache.outputs.cache-hit != 'true'
|
||||
env:
|
||||
MESH_REV_SHORT: ${{ steps.mesh_rev.outputs.short }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo fetch --manifest-path desktop/src-tauri/Cargo.toml
|
||||
SHORT="$MESH_REV_SHORT"
|
||||
MESH_ROOT=$(find "${CARGO_HOME:-$HOME/.cargo}/git/checkouts" -path "*/$SHORT" -type d -name "$SHORT" | head -1)
|
||||
if [[ -z "$MESH_ROOT" ]]; then
|
||||
echo "::error::mesh-llm checkout for $SHORT not found after cargo fetch"
|
||||
exit 1
|
||||
fi
|
||||
export LLAMA_STAGE_BACKEND=metal
|
||||
export LLAMA_STAGE_BUILD_DIR="$GITHUB_WORKSPACE/.cache/mesh-llama/build-stage-abi-metal"
|
||||
export CMAKE_OSX_DEPLOYMENT_TARGET=10.15
|
||||
"$MESH_ROOT/scripts/prepare-llama.sh" pinned
|
||||
"$MESH_ROOT/scripts/build-llama.sh" -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15
|
||||
- name: Save mesh llama build cache
|
||||
if: steps.llama_cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: ${{ github.workspace }}/.cache/mesh-llama
|
||||
key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }}
|
||||
- name: Build Tauri app
|
||||
run: cd desktop && pnpm tauri build
|
||||
env:
|
||||
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
||||
MACOSX_DEPLOYMENT_TARGET: "10.15"
|
||||
CMAKE_OSX_DEPLOYMENT_TARGET: "10.15"
|
||||
LLAMA_STAGE_BACKEND: metal
|
||||
LLAMA_STAGE_BUILD_DIR: ${{ github.workspace }}/.cache/mesh-llama/build-stage-abi-metal
|
||||
SKIPPY_LLAMA_AUTO_BUILD: "0"
|
||||
|
||||
45
.github/workflows/release.yml
vendored
45
.github/workflows/release.yml
vendored
@@ -75,6 +75,46 @@ jobs:
|
||||
cargo build --release -p sprout-acp -p sprout-mcp -p sprout-agent -p sprout-dev-mcp -p git-credential-nostr -p sprout-cli
|
||||
./scripts/bundle-sidecars.sh
|
||||
|
||||
# Mesh rev derived from Cargo.lock (no lockstep edit on dep bump); cache key tracks it.
|
||||
- name: Resolve mesh-llm rev
|
||||
id: mesh_rev
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REV=$(grep -oE 'mesh-llm\.git\?rev=[0-9a-f]{40}' Cargo.lock | head -1 | grep -oE '[0-9a-f]{40}')
|
||||
[[ -n "$REV" ]] || { echo "::error::could not resolve mesh-llm rev from Cargo.lock"; exit 1; }
|
||||
echo "rev=$REV" >> "$GITHUB_OUTPUT"
|
||||
echo "short=${REV:0:7}" >> "$GITHUB_OUTPUT"
|
||||
- name: Restore mesh llama build cache
|
||||
id: llama_cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: ${{ github.workspace }}/.cache/mesh-llama
|
||||
key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }}
|
||||
- name: Build mesh llama native libraries
|
||||
if: steps.llama_cache.outputs.cache-hit != 'true'
|
||||
env:
|
||||
MESH_REV_SHORT: ${{ steps.mesh_rev.outputs.short }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo fetch --manifest-path desktop/src-tauri/Cargo.toml
|
||||
SHORT="$MESH_REV_SHORT"
|
||||
MESH_ROOT=$(find "${CARGO_HOME:-$HOME/.cargo}/git/checkouts" -path "*/$SHORT" -type d -name "$SHORT" | head -1)
|
||||
if [[ -z "$MESH_ROOT" ]]; then
|
||||
echo "::error::mesh-llm checkout for $SHORT not found after cargo fetch"
|
||||
exit 1
|
||||
fi
|
||||
export LLAMA_STAGE_BACKEND=metal
|
||||
export LLAMA_STAGE_BUILD_DIR="$GITHUB_WORKSPACE/.cache/mesh-llama/build-stage-abi-metal"
|
||||
export CMAKE_OSX_DEPLOYMENT_TARGET=10.15
|
||||
"$MESH_ROOT/scripts/prepare-llama.sh" pinned
|
||||
"$MESH_ROOT/scripts/build-llama.sh" -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15
|
||||
- name: Save mesh llama build cache
|
||||
if: steps.llama_cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: ${{ github.workspace }}/.cache/mesh-llama
|
||||
key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }}
|
||||
|
||||
- name: Build unsigned Tauri app
|
||||
run: cd desktop && pnpm tauri build --verbose --no-sign --config src-tauri/tauri.release.conf.json
|
||||
env:
|
||||
@@ -83,6 +123,11 @@ jobs:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
||||
MACOSX_DEPLOYMENT_TARGET: "10.15"
|
||||
CMAKE_OSX_DEPLOYMENT_TARGET: "10.15"
|
||||
LLAMA_STAGE_BACKEND: metal
|
||||
LLAMA_STAGE_BUILD_DIR: ${{ github.workspace }}/.cache/mesh-llama/build-stage-abi-metal
|
||||
SKIPPY_LLAMA_AUTO_BUILD: "0"
|
||||
|
||||
- name: Locate unsigned DMG
|
||||
id: unsigned
|
||||
|
||||
5080
Cargo.lock
generated
5080
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -190,6 +190,7 @@ impl AgentError {
|
||||
pub fn json_rpc_code(&self) -> i32 {
|
||||
match self {
|
||||
Self::InvalidParams(_) => -32602,
|
||||
Self::LlmAuth(_) => -32001,
|
||||
_ => -32000,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +145,12 @@ pub const KIND_NIP29_GROUP_ROLES: u32 = 39003;
|
||||
/// Workflow definition (parameterized replaceable, d=workflow_uuid).
|
||||
pub const KIND_WORKFLOW_DEF: u32 = 30620;
|
||||
|
||||
/// Mesh-LLM relay status (relay-signed, parameterized replaceable, d=sprout-relay-mesh).
|
||||
///
|
||||
/// Published only by the relay. Carries a sanitized, member-readable projection
|
||||
/// of mesh status, including EndpointAddr dial pointers for serving nodes.
|
||||
pub const KIND_MESH_LLM_RELAY_STATUS: u32 = 30621;
|
||||
|
||||
/// Lower bound of the NIP-33 parameterized replaceable range (30000–39999).
|
||||
pub const PARAM_REPLACEABLE_KIND_MIN: u32 = 30000;
|
||||
/// Upper bound of the NIP-33 parameterized replaceable range (30000–39999).
|
||||
@@ -370,6 +376,7 @@ pub const ALL_KINDS: &[u32] = &[
|
||||
KIND_SYSTEM_MESSAGE,
|
||||
KIND_CHANNEL_SUMMARY,
|
||||
KIND_PRESENCE_SNAPSHOT,
|
||||
KIND_MESH_LLM_RELAY_STATUS,
|
||||
KIND_DM_OPEN,
|
||||
KIND_DM_ADD_MEMBER,
|
||||
KIND_DM_HIDE,
|
||||
@@ -480,7 +487,10 @@ pub const fn is_command_kind(kind: u32) -> bool {
|
||||
/// Returns `true` if `kind` is a relay-only sidecar kind.
|
||||
/// Client submission of these kinds must be rejected.
|
||||
pub const fn is_relay_only_kind(kind: u32) -> bool {
|
||||
matches!(kind, KIND_CHANNEL_SUMMARY | KIND_PRESENCE_SNAPSHOT)
|
||||
matches!(
|
||||
kind,
|
||||
KIND_CHANNEL_SUMMARY | KIND_PRESENCE_SNAPSHOT | KIND_MESH_LLM_RELAY_STATUS
|
||||
)
|
||||
}
|
||||
|
||||
/// Extract the kind from a nostr Event as u32.
|
||||
@@ -498,6 +508,7 @@ pub fn event_kind_i32(event: &nostr::Event) -> i32 {
|
||||
// Compile-time: new kinds are in the expected ranges.
|
||||
const _: () = assert!(is_replaceable(KIND_AGENT_PROFILE)); // 10100 ∈ 10000–19999
|
||||
const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999
|
||||
const _: () = assert!(is_parameterized_replaceable(KIND_MESH_LLM_RELAY_STATUS)); // 30621 ∈ 30000–39999
|
||||
|
||||
// Compile-time: NIP-34 parameterized replaceable kinds are in the correct range.
|
||||
const _: () = assert!(
|
||||
|
||||
@@ -65,5 +65,7 @@ metrics-exporter-prometheus = { workspace = true }
|
||||
dev = ["sprout-auth/dev"]
|
||||
|
||||
[dev-dependencies]
|
||||
mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "bd16da4955a9dceee8f1e0a02c5814944f4e12a5", package = "mesh-llm-sdk", default-features = false, features = ["client", "serve"] }
|
||||
sprout-core = { workspace = true, features = ["test-utils"] }
|
||||
sprout-auth = { workspace = true, features = ["dev"] }
|
||||
reqwest = { workspace = true }
|
||||
|
||||
154
crates/sprout-relay/examples/mesh_serve_client_smoke.rs
Normal file
154
crates/sprout-relay/examples/mesh_serve_client_smoke.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
//! Local mesh serve→client→inference smoke test.
|
||||
//!
|
||||
//! Proves the full path Sprout's "Share compute" + "Run on relay mesh" pair
|
||||
//! relies on, on a single box and without a relay or Nostr discovery:
|
||||
//!
|
||||
//! 1. Start a SERVE node hosting a GGUF (the `serve::start` path desktop
|
||||
//! uses in Share-compute mode), and read its mesh invite token.
|
||||
//! 2. Start a CLIENT node joined to that serve node via the invite token
|
||||
//! (the `client::start` path an agent's "Run on relay mesh" provider
|
||||
//! uses), binding its own local OpenAI-compatible endpoint.
|
||||
//! 3. Drive one chat completion against the CLIENT endpoint and assert it
|
||||
//! routed through the mesh to the serve node and produced real output.
|
||||
//!
|
||||
//! Serve-only proves less than the PR claims (serve + client + routing), so
|
||||
//! this exercises the client hop end to end.
|
||||
//!
|
||||
//! This is hardware-gated and NOT a CI test — it loads a real model and runs
|
||||
//! inference. It lives as an example so CI never auto-runs it.
|
||||
//!
|
||||
//! Usage:
|
||||
//! # default model is a ~100MB instruct model, downloaded on first run:
|
||||
//! cargo run -p sprout-relay --example mesh_serve_client_smoke
|
||||
//!
|
||||
//! # or point at any local .gguf / hf model ref (e.g. the on-hardware 35B):
|
||||
//! MESH_SMOKE_MODEL=/path/to/model.gguf \
|
||||
//! cargo run -p sprout-relay --example mesh_serve_client_smoke
|
||||
use std::time::Duration;
|
||||
|
||||
use mesh_llm_sdk::{client, serve, MeshDiscoveryMode};
|
||||
|
||||
/// Small, real instruct model the mesh project itself uses for CI smoke.
|
||||
/// Downloaded on first run; ~100MB.
|
||||
const DEFAULT_MODEL: &str = "jc-builds/SmolLM2-135M-Instruct-Q4_K_M-GGUF:Q4_K_M";
|
||||
|
||||
const SERVE_API_PORT: u16 = 19337;
|
||||
const SERVE_CONSOLE_PORT: u16 = 13131;
|
||||
const CLIENT_API_PORT: u16 = 19338;
|
||||
const CLIENT_CONSOLE_PORT: u16 = 13132;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let model = std::env::var("MESH_SMOKE_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string());
|
||||
eprintln!("[smoke] model: {model}");
|
||||
|
||||
// ── 1. Serve node ────────────────────────────────────────────────────────
|
||||
let serve_cfg = serve::EmbeddedServeConfig::builder()
|
||||
.model(&model)
|
||||
.api_port(SERVE_API_PORT)
|
||||
.console_port(SERVE_CONSOLE_PORT)
|
||||
// publish so the client can join via the invite token; Mdns keeps
|
||||
// discovery local — no relay, no Nostr.
|
||||
.publish(true)
|
||||
.auto_join(false)
|
||||
.discovery_mode(MeshDiscoveryMode::Mdns)
|
||||
// console_ui(true) is required for readiness polling at rev bd16da4
|
||||
// (serve::start polls :console_port/api/status, which only binds when
|
||||
// !headless). See mesh_serve_smoke.rs for the full note.
|
||||
.console_ui(true)
|
||||
.build();
|
||||
|
||||
eprintln!("[smoke] starting serve node...");
|
||||
let serve_node = serve::start(serve_cfg).await?;
|
||||
let serve_base = serve_node.api_base_url().to_string();
|
||||
eprintln!("[smoke] serve up, api_base_url = {serve_base}");
|
||||
|
||||
let invite = serve_node
|
||||
.invite_token()
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| anyhow::anyhow!("serve node produced no invite token to join"))?;
|
||||
eprintln!("[smoke] serve invite token acquired (len {})", invite.len());
|
||||
|
||||
// Wait until the serve node reports the model loaded, so the client has
|
||||
// something to route to.
|
||||
let http = reqwest::Client::new();
|
||||
let serve_model_id = wait_for_model(&http, &serve_base).await?;
|
||||
eprintln!("[smoke] serve model ready: {serve_model_id}");
|
||||
|
||||
// ── 2. Client node, joined to the serve node ─────────────────────────────
|
||||
let client_cfg = client::EmbeddedClientConfig::builder()
|
||||
.api_port(CLIENT_API_PORT)
|
||||
.console_port(CLIENT_CONSOLE_PORT)
|
||||
.publish(false)
|
||||
.auto_join(false)
|
||||
.discovery_mode(MeshDiscoveryMode::Mdns)
|
||||
.join_token(&invite)
|
||||
.console_ui(true)
|
||||
.build();
|
||||
|
||||
eprintln!("[smoke] starting client node joined to serve...");
|
||||
let client_node = client::start(client_cfg).await?;
|
||||
let client_base = client_node.api_base_url().to_string();
|
||||
eprintln!("[smoke] client up, api_base_url = {client_base}");
|
||||
|
||||
// The served model must propagate across the mesh to the client's
|
||||
// /models view before we can route a completion through it.
|
||||
let routed_model_id = wait_for_model(&http, &client_base).await?;
|
||||
eprintln!("[smoke] client sees routed model: {routed_model_id}");
|
||||
|
||||
// ── 3. One real completion, through the CLIENT (mesh hop) ─────────────────
|
||||
let chat_url = format!("{client_base}/chat/completions");
|
||||
let req = serde_json::json!({
|
||||
"model": routed_model_id,
|
||||
"messages": [{"role": "user", "content": "Reply with exactly one word: PONG"}],
|
||||
"max_tokens": 512,
|
||||
"temperature": 0.0
|
||||
});
|
||||
eprintln!("[smoke] POST {chat_url} (routes serve←client over mesh)");
|
||||
let resp = http.post(&chat_url).json(&req).send().await?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
println!("[smoke] completion status={status}");
|
||||
println!("[smoke] completion body={body}");
|
||||
|
||||
// Tear down both nodes before asserting, so a failed assert still cleans up.
|
||||
let _ = client_node.stop().await;
|
||||
let _ = serve_node.stop().await;
|
||||
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("completion through client failed: {status}");
|
||||
}
|
||||
let json: serde_json::Value = serde_json::from_str(&body)?;
|
||||
let content = json["choices"][0]["message"]["content"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
let finish = json["choices"][0]["finish_reason"].as_str().unwrap_or("");
|
||||
if content.trim().is_empty() {
|
||||
anyhow::bail!("completion routed but content was empty");
|
||||
}
|
||||
println!("[smoke] OK — routed completion finish_reason={finish:?} content={content:?}");
|
||||
eprintln!("[smoke] PASS: serve→client→inference proven over mesh");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll a node's `/models` until it reports a model, returning the served id
|
||||
/// (the node assigns its own id, e.g. `local-gguf/sha256-…`, not our ref).
|
||||
async fn wait_for_model(http: &reqwest::Client, api_base: &str) -> anyhow::Result<String> {
|
||||
let url = format!("{api_base}/models");
|
||||
for i in 0..120 {
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
match http.get(&url).send().await {
|
||||
Ok(r) => {
|
||||
let body = r.text().await.unwrap_or_default();
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&body) {
|
||||
if let Some(id) = json["data"].get(0).and_then(|m| m["id"].as_str()) {
|
||||
return Ok(id.to_string());
|
||||
}
|
||||
}
|
||||
eprintln!("[smoke] waiting ({}s) {url} -> {body}", (i + 1) * 5);
|
||||
}
|
||||
Err(e) => eprintln!("[smoke] waiting ({}s) {url} err: {e}", (i + 1) * 5),
|
||||
}
|
||||
}
|
||||
anyhow::bail!("model never became visible at {api_base} within timeout")
|
||||
}
|
||||
96
crates/sprout-relay/examples/mesh_serve_smoke.rs
Normal file
96
crates/sprout-relay/examples/mesh_serve_smoke.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
//! Local mesh-serve inference smoke test.
|
||||
//!
|
||||
//! Serves a GGUF model through the same `mesh_llm_sdk::serve` path Sprout
|
||||
//! desktop uses in "Share compute" mode, then drives one chat completion
|
||||
//! against the node's local OpenAI-compatible endpoint. No mesh publish, no
|
||||
//! auto-join, no Nostr discovery — pure single-node serve-and-self-consume,
|
||||
//! which is exactly the loopback variant we can prove on one box.
|
||||
//!
|
||||
//! Usage:
|
||||
//! cargo run -p sprout-relay --example mesh_serve_smoke -- <path-to.gguf>
|
||||
use std::time::Duration;
|
||||
|
||||
use mesh_llm_sdk::{serve, MeshDiscoveryMode};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let model = std::env::args()
|
||||
.nth(1)
|
||||
.ok_or_else(|| anyhow::anyhow!("usage: mesh_serve_smoke <path-to.gguf>"))?;
|
||||
eprintln!("[smoke] serving model: {model}");
|
||||
|
||||
let config = serve::EmbeddedServeConfig::builder()
|
||||
.model(&model)
|
||||
.api_port(19337)
|
||||
.console_port(13131)
|
||||
.publish(false)
|
||||
.auto_join(false)
|
||||
.discovery_mode(MeshDiscoveryMode::Mdns)
|
||||
// NOTE: console_ui(true) is required here, not cosmetic. In mesh rev
|
||||
// bd16da4, serve::start always polls `:console_port/api/status` to
|
||||
// confirm readiness, but the console HTTP server only binds when
|
||||
// !headless (i.e. console_ui == true). With console_ui(false) the
|
||||
// poll never succeeds and startup times out after 30s. Desktop's
|
||||
// Share-compute path sets console_ui(false) — likely hits the same
|
||||
// wall. Flagged to the team.
|
||||
.console_ui(true)
|
||||
.build();
|
||||
|
||||
let node = serve::start(config).await?;
|
||||
let base = node.api_base_url().to_string();
|
||||
eprintln!("[smoke] node up, api_base_url = {base}");
|
||||
|
||||
// Poll until the model reports loaded/ready (give it generous time — first
|
||||
// load of a 17GB GGUF into Metal can take a while).
|
||||
let http = reqwest::Client::new();
|
||||
let models_url = format!("{base}/models");
|
||||
let mut model_id = String::new();
|
||||
for i in 0..120 {
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
match http.get(&models_url).send().await {
|
||||
Ok(r) => {
|
||||
let body = r.text().await.unwrap_or_default();
|
||||
// The serve node assigns its own id (e.g. local-gguf/sha256-…),
|
||||
// not the file path we passed — pull it from /models.
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&body) {
|
||||
if let Some(id) = json["data"].get(0).and_then(|m| m["id"].as_str()) {
|
||||
model_id = id.to_string();
|
||||
eprintln!(
|
||||
"[smoke] /models after {}s, served id = {model_id}",
|
||||
(i + 1) * 5
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
eprintln!("[smoke] waiting ({}s) /models -> {body}", (i + 1) * 5);
|
||||
}
|
||||
Err(e) => eprintln!("[smoke] waiting ({}s) /models err: {e}", (i + 1) * 5),
|
||||
}
|
||||
}
|
||||
if model_id.is_empty() {
|
||||
anyhow::bail!("model never became ready within timeout");
|
||||
}
|
||||
|
||||
// One real completion — use the server's own model id.
|
||||
let chat_url = format!("{base}/chat/completions");
|
||||
let req = serde_json::json!({
|
||||
"model": model_id,
|
||||
"messages": [
|
||||
{"role": "user", "content": "Reply with exactly one word: PONG"}
|
||||
],
|
||||
"max_tokens": 512,
|
||||
"temperature": 0.0
|
||||
});
|
||||
eprintln!("[smoke] POST {chat_url}");
|
||||
let resp = http.post(&chat_url).json(&req).send().await?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
println!("[smoke] completion status={status}");
|
||||
println!("[smoke] completion body={body}");
|
||||
|
||||
node.stop().await?;
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("completion request failed: {status}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -38,6 +38,70 @@ pub mod relay_members {
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Transport-neutral outcome of a relay-membership check.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum MembershipDecision {
|
||||
/// Relay membership enforcement is disabled.
|
||||
OpenRelay,
|
||||
/// Caller is directly present in `relay_members`.
|
||||
Member,
|
||||
/// Caller is admitted through a NIP-OA owner that is a relay member.
|
||||
ViaOwner(nostr::PublicKey),
|
||||
/// Caller is not admitted.
|
||||
Denied,
|
||||
}
|
||||
|
||||
/// Check relay membership without committing to an HTTP response shape.
|
||||
pub async fn check_relay_membership(
|
||||
state: &AppState,
|
||||
pubkey_bytes: &[u8],
|
||||
auth_tag_header: Option<&str>,
|
||||
) -> Result<MembershipDecision, String> {
|
||||
if !state.config.require_relay_membership {
|
||||
return Ok(MembershipDecision::OpenRelay);
|
||||
}
|
||||
|
||||
let pubkey_hex = hex::encode(pubkey_bytes);
|
||||
let is_member = state
|
||||
.db
|
||||
.is_relay_member(&pubkey_hex)
|
||||
.await
|
||||
.map_err(|e| format!("relay membership check failed: {e}"))?;
|
||||
if is_member {
|
||||
return Ok(MembershipDecision::Member);
|
||||
}
|
||||
|
||||
if state.config.allow_nip_oa_auth {
|
||||
if let Some(tag_json) = auth_tag_header {
|
||||
let agent_pubkey = nostr::PublicKey::from_slice(pubkey_bytes)
|
||||
.map_err(|e| format!("invalid agent pubkey for NIP-OA check: {e}"))?;
|
||||
|
||||
match sprout_sdk::nip_oa::verify_auth_tag(tag_json, &agent_pubkey) {
|
||||
Ok(owner_pubkey) => {
|
||||
let owner_hex = owner_pubkey.to_hex();
|
||||
let owner_is_member =
|
||||
state.db.is_relay_member(&owner_hex).await.map_err(|e| {
|
||||
format!("relay membership check (owner) failed: {e}")
|
||||
})?;
|
||||
if owner_is_member {
|
||||
debug!(
|
||||
agent = %pubkey_hex,
|
||||
owner = %owner_hex,
|
||||
"NIP-OA membership granted via owner"
|
||||
);
|
||||
return Ok(MembershipDecision::ViaOwner(owner_pubkey));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
info!(agent = %pubkey_hex, "NIP-OA auth tag invalid: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MembershipDecision::Denied)
|
||||
}
|
||||
|
||||
/// Enforce relay membership for a pubkey, with NIP-OA agent delegation fallback.
|
||||
///
|
||||
/// Returns `Ok(Some(owner_pubkey))` when the agent is not a direct member but
|
||||
@@ -54,60 +118,21 @@ pub mod relay_members {
|
||||
pubkey_bytes: &[u8],
|
||||
auth_tag_header: Option<&str>,
|
||||
) -> Result<Option<nostr::PublicKey>, (StatusCode, Json<serde_json::Value>)> {
|
||||
if !state.config.require_relay_membership {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let pubkey_hex = hex::encode(pubkey_bytes);
|
||||
let is_member = state.db.is_relay_member(&pubkey_hex).await.map_err(|e| {
|
||||
tracing::error!("relay membership check failed: {e}");
|
||||
super::internal_error(&format!("relay membership check failed: {e}"))
|
||||
})?;
|
||||
|
||||
if is_member {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// NIP-OA fallback: check if agent's owner is a relay member.
|
||||
if state.config.allow_nip_oa_auth {
|
||||
if let Some(tag_json) = auth_tag_header {
|
||||
let agent_pubkey = nostr::PublicKey::from_slice(pubkey_bytes).map_err(|e| {
|
||||
super::internal_error(&format!("invalid agent pubkey for NIP-OA check: {e}"))
|
||||
})?;
|
||||
|
||||
match sprout_sdk::nip_oa::verify_auth_tag(tag_json, &agent_pubkey) {
|
||||
Ok(owner_pubkey) => {
|
||||
let owner_hex = owner_pubkey.to_hex();
|
||||
let owner_is_member =
|
||||
state.db.is_relay_member(&owner_hex).await.map_err(|e| {
|
||||
super::internal_error(&format!(
|
||||
"relay membership check (owner) failed: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
if owner_is_member {
|
||||
debug!(
|
||||
agent = %pubkey_hex,
|
||||
owner = %owner_hex,
|
||||
"NIP-OA membership granted via owner"
|
||||
);
|
||||
return Ok(Some(owner_pubkey));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
info!(agent = %pubkey_hex, "NIP-OA auth tag invalid: {e}");
|
||||
}
|
||||
}
|
||||
match check_relay_membership(state, pubkey_bytes, auth_tag_header).await {
|
||||
Ok(MembershipDecision::OpenRelay) | Ok(MembershipDecision::Member) => Ok(None),
|
||||
Ok(MembershipDecision::ViaOwner(owner)) => Ok(Some(owner)),
|
||||
Ok(MembershipDecision::Denied) => Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "relay_membership_required",
|
||||
"message": "You must be a relay member to access this relay"
|
||||
})),
|
||||
)),
|
||||
Err(e) => {
|
||||
tracing::error!("relay membership check errored: {e}");
|
||||
Err(super::internal_error(&e))
|
||||
}
|
||||
}
|
||||
|
||||
Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "relay_membership_required",
|
||||
"message": "You must be a relay member to access this relay"
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
/// Extract NIP-OA owner from an auth tag without membership enforcement.
|
||||
|
||||
@@ -22,16 +22,16 @@ use sprout_core::kind::{
|
||||
KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED,
|
||||
KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, KIND_IA_ARCHIVE_REQUEST,
|
||||
KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MEMBER_ADDED_NOTIFICATION,
|
||||
KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP,
|
||||
KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA,
|
||||
KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER,
|
||||
KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE,
|
||||
KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_STREAM_MESSAGE,
|
||||
KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT,
|
||||
KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2,
|
||||
KIND_STREAM_REMINDER, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF,
|
||||
KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE,
|
||||
RELAY_ADMIN_REMOVE_MEMBER,
|
||||
KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MESH_LLM_RELAY_STATUS, KIND_MUTE_LIST,
|
||||
KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP,
|
||||
KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST,
|
||||
KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST,
|
||||
KIND_NIP65_RELAY_LIST_METADATA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, KIND_PROFILE,
|
||||
KIND_REACTION, KIND_READ_STATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED,
|
||||
KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED,
|
||||
KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEXT_NOTE,
|
||||
KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER,
|
||||
RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER,
|
||||
};
|
||||
use sprout_core::verification::verify_event;
|
||||
|
||||
@@ -353,6 +353,9 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool {
|
||||
// events. A stray `h` tag must not channel-scope them.
|
||||
| KIND_IA_ARCHIVE_REQUEST
|
||||
| KIND_IA_UNARCHIVE_REQUEST
|
||||
// Mesh-LLM relay status is relay-signed and global. Clients may
|
||||
// subscribe to it, but must not channel-scope or submit it.
|
||||
| KIND_MESH_LLM_RELAY_STATUS
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1931,6 +1934,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_llm_relay_status_is_global_only_and_relay_only() {
|
||||
assert!(is_global_only_kind(KIND_MESH_LLM_RELAY_STATUS));
|
||||
assert!(sprout_core::kind::is_relay_only_kind(
|
||||
KIND_MESH_LLM_RELAY_STATUS
|
||||
));
|
||||
assert!(!requires_h_channel_scope(KIND_MESH_LLM_RELAY_STATUS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nip51_and_nip65_lists_are_global_only() {
|
||||
for kind in [
|
||||
|
||||
236
crates/sprout-relay/src/iroh_relay.rs
Normal file
236
crates/sprout-relay/src/iroh_relay.rs
Normal file
@@ -0,0 +1,236 @@
|
||||
//! Sprout admission helpers for mesh-LLM iroh-relay connections.
|
||||
//!
|
||||
//! This module intentionally keeps the admission logic independent from the
|
||||
//! concrete `iroh-relay` server type for the first landing. Max's runtime lane
|
||||
//! owns adding the iroh dependency and wiring this decision into
|
||||
//! `AccessConfig::Restricted`. The security boundary lives here: a connecting
|
||||
//! endpoint must present a NIP-98 bearer signed by a direct relay member.
|
||||
//!
|
||||
//! V1 deliberately admits direct relay members only. NIP-OA owner delegation for
|
||||
//! agent-owned mesh nodes is a follow-up so the mesh compute trust boundary stays
|
||||
//! legible.
|
||||
|
||||
use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD};
|
||||
use base64::Engine;
|
||||
use nostr::PublicKey;
|
||||
|
||||
use crate::api::relay_members::MembershipDecision;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Path component appended to the configured iroh-relay public URL.
|
||||
pub const IROH_RELAY_PATH: &str = "/relay";
|
||||
|
||||
const NIP98_METHOD: &str = "GET";
|
||||
const MAX_BEARER_LEN: usize = 64 * 1024;
|
||||
|
||||
/// Result of a mesh iroh admission check.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum IrohAdmissionDecision {
|
||||
/// Allow the connection for this direct relay member.
|
||||
Allow {
|
||||
/// NIP-98-proven member pubkey.
|
||||
pubkey: PublicKey,
|
||||
},
|
||||
/// Deny the connection. Reason is for server logs/tests, not client display.
|
||||
Deny(String),
|
||||
}
|
||||
|
||||
/// Decide whether an iroh-relay connection should be admitted.
|
||||
pub async fn decide_admission(
|
||||
state: &AppState,
|
||||
canonical_url: &str,
|
||||
auth_token: Option<&str>,
|
||||
) -> IrohAdmissionDecision {
|
||||
let pubkey = match verify_bearer(canonical_url, auth_token) {
|
||||
Ok(pubkey) => pubkey,
|
||||
Err(reason) => return IrohAdmissionDecision::Deny(reason),
|
||||
};
|
||||
|
||||
let membership =
|
||||
crate::api::relay_members::check_relay_membership(state, &pubkey.to_bytes(), None).await;
|
||||
admission_from_membership(membership, pubkey)
|
||||
}
|
||||
|
||||
/// Map a relay-membership outcome to an iroh admission decision.
|
||||
///
|
||||
/// This is the mesh admission *invariant*, isolated from any I/O so it can be
|
||||
/// asserted directly: relay membership is the only thing that admits. A direct
|
||||
/// member (or an open relay) is allowed; everything else — non-members,
|
||||
/// NIP-OA owner-delegated agents (denied in v1), and membership-check errors —
|
||||
/// is denied. Possession of dial metadata or a valid NIP-98 bearer is by itself
|
||||
/// never sufficient; only membership flips this to `Allow`.
|
||||
pub fn admission_from_membership(
|
||||
membership: Result<MembershipDecision, String>,
|
||||
pubkey: PublicKey,
|
||||
) -> IrohAdmissionDecision {
|
||||
match membership {
|
||||
Ok(MembershipDecision::OpenRelay) | Ok(MembershipDecision::Member) => {
|
||||
IrohAdmissionDecision::Allow { pubkey }
|
||||
}
|
||||
Ok(MembershipDecision::ViaOwner(_)) => IrohAdmissionDecision::Deny(
|
||||
// v1 deliberately denies NIP-OA owner-delegated agents even when their
|
||||
// owner is a relay member. HTTP endpoints accept that delegation; iroh
|
||||
// admission does not, keeping the mesh-compute trust boundary tighter
|
||||
// and legible. Lifting this is a follow-up gated on NIP-OA scope review.
|
||||
"owner-delegated mesh admission is not enabled in v1".to_string(),
|
||||
),
|
||||
Ok(MembershipDecision::Denied) => {
|
||||
IrohAdmissionDecision::Deny(format!("not a relay member: {pubkey}"))
|
||||
}
|
||||
Err(error) => IrohAdmissionDecision::Deny(format!("membership check failed: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify a base64-encoded NIP-98 bearer for the iroh-relay WebSocket URL.
|
||||
pub fn verify_bearer(canonical_url: &str, auth_token: Option<&str>) -> Result<PublicKey, String> {
|
||||
let token = match auth_token {
|
||||
Some(token) if !token.trim().is_empty() => token.trim(),
|
||||
_ => return Err("missing bearer token".to_string()),
|
||||
};
|
||||
if token.len() > MAX_BEARER_LEN {
|
||||
return Err(format!("bearer token exceeds {MAX_BEARER_LEN} bytes"));
|
||||
}
|
||||
|
||||
let json = decode_bearer(token).ok_or_else(|| "bearer token is not base64 JSON".to_string())?;
|
||||
sprout_auth::verify_nip98_event(&json, canonical_url, NIP98_METHOD, None)
|
||||
.map_err(|error| format!("NIP-98 verification failed: {error}"))
|
||||
}
|
||||
|
||||
fn decode_bearer(token: &str) -> Option<String> {
|
||||
for engine in [STANDARD, URL_SAFE, STANDARD_NO_PAD, URL_SAFE_NO_PAD] {
|
||||
if let Ok(bytes) = engine.decode(token) {
|
||||
if let Ok(json) = String::from_utf8(bytes) {
|
||||
return Some(json);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||||
|
||||
fn bearer_for(keys: &Keys, canonical_url: &str, method: &str) -> String {
|
||||
let event = EventBuilder::new(Kind::HttpAuth, "")
|
||||
.tags([
|
||||
Tag::parse(["u", canonical_url]).unwrap(),
|
||||
Tag::parse(["method", method]).unwrap(),
|
||||
])
|
||||
.custom_created_at(Timestamp::now())
|
||||
.sign_with_keys(keys)
|
||||
.unwrap();
|
||||
STANDARD.encode(serde_json::to_string(&event).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_bearer_accepts_valid_nip98() {
|
||||
let keys = Keys::generate();
|
||||
let url = "https://relay.example/iroh/relay";
|
||||
let token = bearer_for(&keys, url, NIP98_METHOD);
|
||||
|
||||
assert_eq!(verify_bearer(url, Some(&token)).unwrap(), keys.public_key());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_bearer_rejects_missing_token() {
|
||||
assert!(verify_bearer("https://relay.example/iroh/relay", None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_bearer_rejects_wrong_method() {
|
||||
let keys = Keys::generate();
|
||||
let url = "https://relay.example/iroh/relay";
|
||||
let token = bearer_for(&keys, url, "POST");
|
||||
|
||||
let error = verify_bearer(url, Some(&token)).unwrap_err();
|
||||
assert!(error.contains("method"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_bearer_rejects_wrong_url() {
|
||||
let keys = Keys::generate();
|
||||
let token = bearer_for(&keys, "https://other.example/iroh/relay", NIP98_METHOD);
|
||||
|
||||
let error = verify_bearer("https://relay.example/iroh/relay", Some(&token)).unwrap_err();
|
||||
assert!(error.contains("URL"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_bearer_rejects_expired_timestamp() {
|
||||
// NIP-98's ±60s window must reject stale bearers — guards against
|
||||
// observed-token replay outside the live admission moment.
|
||||
let keys = Keys::generate();
|
||||
let url = "https://relay.example/iroh/relay";
|
||||
let stale = Timestamp::now() - 120u64;
|
||||
let event = EventBuilder::new(Kind::HttpAuth, "")
|
||||
.tags([
|
||||
Tag::parse(["u", url]).unwrap(),
|
||||
Tag::parse(["method", NIP98_METHOD]).unwrap(),
|
||||
])
|
||||
.custom_created_at(stale)
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
let token = STANDARD.encode(serde_json::to_string(&event).unwrap());
|
||||
|
||||
let error = verify_bearer(url, Some(&token)).unwrap_err();
|
||||
assert!(error.contains("window"), "{error}");
|
||||
}
|
||||
// ── Admission invariant: relay membership is the ONLY factor ──────────────
|
||||
//
|
||||
// These assert the policy mapping directly (no db/AppState needed). The
|
||||
// bearer-proof layer is covered by the verify_bearer tests above; here we
|
||||
// pin that a *proven* identity is admitted iff it is a relay member.
|
||||
|
||||
fn any_pubkey() -> PublicKey {
|
||||
Keys::generate().public_key()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_allows_direct_relay_member() {
|
||||
let pk = any_pubkey();
|
||||
let decision = admission_from_membership(Ok(MembershipDecision::Member), pk);
|
||||
assert_eq!(decision, IrohAdmissionDecision::Allow { pubkey: pk });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_allows_when_relay_is_open() {
|
||||
// require_relay_membership disabled → OpenRelay → admitted.
|
||||
let pk = any_pubkey();
|
||||
let decision = admission_from_membership(Ok(MembershipDecision::OpenRelay), pk);
|
||||
assert_eq!(decision, IrohAdmissionDecision::Allow { pubkey: pk });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_denies_non_member() {
|
||||
// A valid Nostr identity that is not a relay member gets nothing.
|
||||
let pk = any_pubkey();
|
||||
let decision = admission_from_membership(Ok(MembershipDecision::Denied), pk);
|
||||
assert!(matches!(decision, IrohAdmissionDecision::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_denies_owner_delegation_in_v1() {
|
||||
// NIP-OA owner-delegated agents are explicitly NOT admitted to the mesh
|
||||
// in v1, even though HTTP endpoints accept the same delegation.
|
||||
let pk = any_pubkey();
|
||||
let owner = any_pubkey();
|
||||
let decision = admission_from_membership(Ok(MembershipDecision::ViaOwner(owner)), pk);
|
||||
match decision {
|
||||
IrohAdmissionDecision::Deny(reason) => {
|
||||
assert!(reason.contains("owner-delegated"), "{reason}");
|
||||
}
|
||||
other => panic!("owner delegation must be denied, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_denies_on_membership_check_error() {
|
||||
// Fail closed: an errored membership lookup denies, never admits.
|
||||
let pk = any_pubkey();
|
||||
let decision = admission_from_membership(Err("db down".to_string()), pk);
|
||||
assert!(matches!(decision, IrohAdmissionDecision::Deny(_)));
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,10 @@ pub mod connection;
|
||||
pub mod error;
|
||||
/// WebSocket message handlers for NIP-01 client commands.
|
||||
pub mod handlers;
|
||||
/// Mesh iroh-relay admission helpers.
|
||||
pub mod iroh_relay;
|
||||
/// Relay-signed mesh-LLM status publisher.
|
||||
pub mod mesh_status_publisher;
|
||||
/// Prometheus metrics: recorder, upkeep, HTTP middleware.
|
||||
pub mod metrics;
|
||||
/// NIP-11 relay information document.
|
||||
|
||||
340
crates/sprout-relay/src/mesh_status_publisher.rs
Normal file
340
crates/sprout-relay/src/mesh_status_publisher.rs
Normal file
@@ -0,0 +1,340 @@
|
||||
//! Relay-signed mesh-LLM status publication.
|
||||
//!
|
||||
//! The relay owns the Nostr publication surface for Sprout mesh status. mesh-llm
|
||||
//! remains the source of live runtime truth, but the relay sanitizes that status
|
||||
//! and republishes a member-readable, relay-signed parameterized event. This
|
||||
//! keeps discovery inside Sprout's relay-membership boundary and avoids mesh's
|
||||
//! public-relay publisher path.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use nostr::{EventBuilder, Kind, Tag};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sprout_core::kind::KIND_MESH_LLM_RELAY_STATUS;
|
||||
use tracing::info;
|
||||
|
||||
use crate::handlers::event::dispatch_persistent_event;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Stable d-tag for the relay's mesh status event.
|
||||
pub const MESH_STATUS_D_TAG: &str = "sprout-relay-mesh";
|
||||
|
||||
/// Content schema discriminator.
|
||||
pub const MESH_STATUS_TYPE: &str = "sprout-mesh-status";
|
||||
|
||||
/// Sanitized relay-published mesh status.
|
||||
///
|
||||
/// Carries dial metadata (mesh identity + per-target `endpoint_addr` invite
|
||||
/// tokens), not access grants. iroh admission via NIP-98 → relay membership is
|
||||
/// the only gate; possession of these fields confers no ability to dial a
|
||||
/// serving node that has not admitted the caller's pubkey.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SproutMeshStatus {
|
||||
/// Schema version.
|
||||
pub v: u32,
|
||||
/// Constant discriminator: `sprout-mesh-status`.
|
||||
#[serde(rename = "type")]
|
||||
pub status_type: String,
|
||||
/// Unix timestamp when the relay generated this projection.
|
||||
pub updated_at: u64,
|
||||
/// Mesh identity, if mesh-llm has joined/created one.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mesh_id: Option<String>,
|
||||
/// Human mesh name, if configured.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mesh_name: Option<String>,
|
||||
/// Serving targets reachable by relay members.
|
||||
pub serve_targets: Vec<MeshServeTarget>,
|
||||
/// Deduplicated model options for agent/provider pickers.
|
||||
pub models: Vec<MeshModelOption>,
|
||||
/// Aggregate peer count from mesh status.
|
||||
pub peer_count: usize,
|
||||
}
|
||||
|
||||
/// Model value + display label. `id` is the API/routing value; `name` is UI-only.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeshModelOption {
|
||||
/// Stable routable OpenAI model id/ref. This is the value clients pass to `/v1`.
|
||||
pub id: String,
|
||||
/// Optional UI label. Must never replace [`Self::id`] for routing.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// A model served at an EndpointAddr dial pointer.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeshServeTarget {
|
||||
/// Stable routable OpenAI model id/ref served at this target.
|
||||
pub model_id: String,
|
||||
/// Optional UI label for [`Self::model_id`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model_name: Option<String>,
|
||||
/// mesh-llm invite token: base64(json(EndpointAddr)). This is dial
|
||||
/// metadata, not an access grant; iroh admission remains the gate.
|
||||
pub endpoint_addr: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
/// Optional node label/hostname for UI display.
|
||||
pub node_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
/// Optional serving capacity hint for this target.
|
||||
pub capacity: Option<MeshTargetCapacity>,
|
||||
}
|
||||
|
||||
/// Capacity hint for a serving target.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeshTargetCapacity {
|
||||
/// Advertised VRAM capacity in GB, if known.
|
||||
pub vram_gb: Option<f64>,
|
||||
}
|
||||
|
||||
/// Build the relay-owned status projection from mesh's `/api/status` JSON.
|
||||
pub fn sanitize_mesh_status(payload: &Value, now_unix: u64) -> SproutMeshStatus {
|
||||
let endpoint_addr = string_field(payload, "token").unwrap_or_default();
|
||||
let node_id = string_field(payload, "node_id");
|
||||
let mesh_id = string_field(payload, "mesh_id");
|
||||
let mesh_name = string_field(payload, "mesh_name");
|
||||
let my_vram_gb = payload.get("my_vram_gb").and_then(Value::as_f64);
|
||||
|
||||
let mut models = Vec::<MeshModelOption>::new();
|
||||
let mut serve_targets = Vec::<MeshServeTarget>::new();
|
||||
|
||||
for model_id in string_array_field(payload, "hosted_models")
|
||||
.into_iter()
|
||||
.chain(string_array_field(payload, "serving_models"))
|
||||
{
|
||||
push_model(&mut models, &model_id, None);
|
||||
if !endpoint_addr.is_empty() {
|
||||
push_target(
|
||||
&mut serve_targets,
|
||||
MeshServeTarget {
|
||||
model_id,
|
||||
model_name: None,
|
||||
endpoint_addr: endpoint_addr.clone(),
|
||||
node_name: node_id.clone(),
|
||||
capacity: Some(MeshTargetCapacity {
|
||||
vram_gb: my_vram_gb,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let peers = payload
|
||||
.get("peers")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
for peer in &peers {
|
||||
let peer_endpoint = string_field(peer, "invite_token")
|
||||
.or_else(|| string_field(peer, "endpoint_addr"))
|
||||
.or_else(|| string_field(peer, "endpointAddr"));
|
||||
let peer_name = string_field(peer, "hostname").or_else(|| string_field(peer, "id"));
|
||||
let peer_vram_gb = peer.get("vram_gb").and_then(Value::as_f64);
|
||||
|
||||
for model_id in string_array_field(peer, "hosted_models")
|
||||
.into_iter()
|
||||
.chain(string_array_field(peer, "serving_models"))
|
||||
{
|
||||
push_model(&mut models, &model_id, None);
|
||||
if let Some(endpoint_addr) = peer_endpoint.clone() {
|
||||
push_target(
|
||||
&mut serve_targets,
|
||||
MeshServeTarget {
|
||||
model_id,
|
||||
model_name: None,
|
||||
endpoint_addr,
|
||||
node_name: peer_name.clone(),
|
||||
capacity: Some(MeshTargetCapacity {
|
||||
vram_gb: peer_vram_gb,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
models.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
serve_targets.sort_by(|a, b| {
|
||||
a.model_id
|
||||
.cmp(&b.model_id)
|
||||
.then_with(|| a.endpoint_addr.cmp(&b.endpoint_addr))
|
||||
});
|
||||
|
||||
SproutMeshStatus {
|
||||
v: 1,
|
||||
status_type: MESH_STATUS_TYPE.to_string(),
|
||||
updated_at: now_unix,
|
||||
mesh_id,
|
||||
mesh_name,
|
||||
serve_targets,
|
||||
models,
|
||||
peer_count: peers.len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish sanitized mesh status as a relay-signed kind:30621 event.
|
||||
pub async fn publish_mesh_status_from_payload(
|
||||
state: &Arc<AppState>,
|
||||
payload: &Value,
|
||||
) -> anyhow::Result<()> {
|
||||
let now_unix = chrono::Utc::now().timestamp().max(0) as u64;
|
||||
let status = sanitize_mesh_status(payload, now_unix);
|
||||
publish_mesh_status(state, &status).await
|
||||
}
|
||||
|
||||
/// Publish a pre-sanitized mesh status. Exposed for tests and integration seams.
|
||||
pub async fn publish_mesh_status(
|
||||
state: &Arc<AppState>,
|
||||
status: &SproutMeshStatus,
|
||||
) -> anyhow::Result<()> {
|
||||
let content = serde_json::to_string(status)?;
|
||||
let tags = vec![
|
||||
Tag::parse(["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?,
|
||||
Tag::parse(["d", MESH_STATUS_D_TAG])
|
||||
.map_err(|e| anyhow::anyhow!("failed to build d tag: {e}"))?,
|
||||
Tag::parse(["k", MESH_STATUS_TYPE])
|
||||
.map_err(|e| anyhow::anyhow!("failed to build k tag: {e}"))?,
|
||||
];
|
||||
|
||||
let event = EventBuilder::new(Kind::Custom(KIND_MESH_LLM_RELAY_STATUS as u16), content)
|
||||
.tags(tags)
|
||||
.sign_with_keys(&state.relay_keypair)
|
||||
.map_err(|e| anyhow::anyhow!("failed to sign kind:{KIND_MESH_LLM_RELAY_STATUS}: {e}"))?;
|
||||
|
||||
let (stored, was_inserted) = state
|
||||
.db
|
||||
.replace_parameterized_event(&event, MESH_STATUS_D_TAG, None)
|
||||
.await?;
|
||||
if was_inserted {
|
||||
let relay_pubkey_hex = state.relay_keypair.public_key().to_hex();
|
||||
dispatch_persistent_event(
|
||||
state,
|
||||
&stored,
|
||||
KIND_MESH_LLM_RELAY_STATUS,
|
||||
&relay_pubkey_hex,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
info!(
|
||||
targets = status.serve_targets.len(),
|
||||
models = status.models.len(),
|
||||
"mesh-LLM relay status published"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn string_field(value: &Value, key: &str) -> Option<String> {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn string_array_field(value: &Value, key: &str) -> Vec<String> {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.map(|values| {
|
||||
values
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn push_model(models: &mut Vec<MeshModelOption>, id: &str, name: Option<String>) {
|
||||
if models.iter().any(|model| model.id == id) {
|
||||
return;
|
||||
}
|
||||
models.push(MeshModelOption {
|
||||
id: id.to_string(),
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
fn push_target(targets: &mut Vec<MeshServeTarget>, target: MeshServeTarget) {
|
||||
if targets.iter().any(|existing| {
|
||||
existing.model_id == target.model_id && existing.endpoint_addr == target.endpoint_addr
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
targets.push(target);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sanitizer_projects_models_and_endpoint_addr_without_raw_runtime() {
|
||||
let payload = serde_json::json!({
|
||||
"token": "endpoint-token-a",
|
||||
"node_id": "node-a",
|
||||
"mesh_id": "mesh-1",
|
||||
"mesh_name": "sprout",
|
||||
"hosted_models": ["Qwen3-8B-Q4_K_M"],
|
||||
"serving_models": ["Qwen3-8B-Q4_K_M"],
|
||||
"my_vram_gb": 12.0,
|
||||
"runtime": { "stages": [{ "source_model_path": "/secret/model.gguf" }] },
|
||||
"local_instances": [{ "runtime_dir": "/tmp/private" }],
|
||||
"peers": [{
|
||||
"id": "peer-b",
|
||||
"hostname": "gpu-b",
|
||||
"invite_token": "endpoint-token-b",
|
||||
"hosted_models": ["TinyLlama"],
|
||||
"vram_gb": 8.0
|
||||
}]
|
||||
});
|
||||
|
||||
let status = sanitize_mesh_status(&payload, 123);
|
||||
assert_eq!(status.status_type, MESH_STATUS_TYPE);
|
||||
assert_eq!(status.mesh_id.as_deref(), Some("mesh-1"));
|
||||
assert_eq!(status.models.len(), 2);
|
||||
assert!(status.models.iter().any(|m| m.id == "Qwen3-8B-Q4_K_M"));
|
||||
assert!(status.models.iter().any(|m| m.id == "TinyLlama"));
|
||||
assert_eq!(status.serve_targets.len(), 2);
|
||||
assert!(status
|
||||
.serve_targets
|
||||
.iter()
|
||||
.any(|target| target.model_id == "Qwen3-8B-Q4_K_M"
|
||||
&& target.endpoint_addr == "endpoint-token-a"));
|
||||
assert!(status
|
||||
.serve_targets
|
||||
.iter()
|
||||
.any(|target| target.model_id == "TinyLlama"
|
||||
&& target.endpoint_addr == "endpoint-token-b"));
|
||||
|
||||
let serialized = serde_json::to_string(&status).unwrap();
|
||||
assert!(!serialized.contains("source_model_path"));
|
||||
assert!(!serialized.contains("runtime_dir"));
|
||||
assert!(!serialized.contains("/secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitizer_keeps_model_id_separate_from_label() {
|
||||
let payload = serde_json::json!({
|
||||
"token": "endpoint-token-a",
|
||||
"hosted_models": ["hf://meshllm/demo@main"],
|
||||
"peers": []
|
||||
});
|
||||
|
||||
let status = sanitize_mesh_status(&payload, 123);
|
||||
assert_eq!(status.models[0].id, "hf://meshllm/demo@main");
|
||||
assert_eq!(status.models[0].name, None);
|
||||
assert_eq!(status.serve_targets[0].model_id, "hf://meshllm/demo@main");
|
||||
assert_eq!(status.serve_targets[0].model_name, None);
|
||||
}
|
||||
}
|
||||
286
crates/sprout-test-client/tests/e2e_mesh_llm.rs
Normal file
286
crates/sprout-test-client/tests/e2e_mesh_llm.rs
Normal file
@@ -0,0 +1,286 @@
|
||||
//! End-to-end acceptance tests for the relay-hosted mesh-LLM feature.
|
||||
//!
|
||||
//! These tests require a running sprout-relay with mesh embedded
|
||||
//! (`SPROUT_MESH_ENABLED=true`, `SPROUT_REQUIRE_RELAY_MEMBERSHIP=true`) and,
|
||||
//! for the live-inference rows, two desktop mesh nodes (serve + client).
|
||||
//! All tests are `#[ignore]` by default — they need infra CI does not host
|
||||
//! (native llama, multi-node, model download). The deterministic trust
|
||||
//! invariants are unit-tested in `sprout-relay` (`mesh_status_publisher`,
|
||||
//! `iroh_relay`); this file is the opt-in full-stack acceptance layer.
|
||||
//!
|
||||
//! # Running (manual / runbook)
|
||||
//!
|
||||
//! ```text
|
||||
//! # 1. one-time local llama build (see docs/mesh-llm-local-build.md)
|
||||
//! # 2. start a mesh-enabled relay
|
||||
//! SPROUT_MESH_ENABLED=true SPROUT_REQUIRE_RELAY_MEMBERSHIP=true \
|
||||
//! cargo run -p sprout-relay
|
||||
//! # 3. run the trust assertions (no GPU needed):
|
||||
//! RELAY_URL=ws://localhost:3000 \
|
||||
//! cargo test --test e2e_mesh_llm trust -- --ignored --nocapture
|
||||
//! # 4. run the live A->B inference row (needs 2 mesh nodes + a small model):
|
||||
//! # point at B's local OpenAI endpoint; without it the test SKIPS (no silent pass):
|
||||
//! MESH_OPENAI_BASE=http://127.0.0.1:9337/v1 \
|
||||
//! cargo test --test e2e_mesh_llm live_agent_completes -- --ignored --nocapture
|
||||
//! # trust rows need real relay identities, not generated keys:
|
||||
//! MEMBER_NSEC=nsec1... STRANGER_NSEC=nsec1... \
|
||||
//! cargo test --test e2e_mesh_llm trust -- --ignored --nocapture
|
||||
//! ```
|
||||
//!
|
||||
//! ## Acceptance matrix (= the demo, as a test)
|
||||
//! | # | Assertion | This file | Also covered by |
|
||||
//! |---|-----------|-----------|-----------------|
|
||||
//! | 1 | member reads kind:30621 status w/ dial pointer, no secrets | `trust_member_reads_mesh_status` | relay `mesh_status_publisher` units |
|
||||
//! | 2 | non-member REQ for kind:30621 returns nothing | `trust_nonmember_read_denied` | — |
|
||||
//! | 3 | non-member iroh dial denied (NIP-98→membership) | runbook (needs iroh dial) | relay `iroh_relay` admission units |
|
||||
//! | 4 | B's agent completes a chat against A's model over mesh | `live_agent_completes_chat_over_mesh` | runbook |
|
||||
//! | 5 | dropped member → typed auth failure reaches lastError | runbook (desktop harness) | sprout-agent `-32001` unit |
|
||||
//! | 6 | split: model too big → 2 serve nodes → chat completes | `live_split_model_completes` | runbook |
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use nostr::{Filter, Keys, Kind};
|
||||
use sprout_test_client::SproutTestClient;
|
||||
|
||||
/// Sprout's relay-owned mesh status kind (must match `sprout_core::kind`).
|
||||
const KIND_MESH_LLM_RELAY_STATUS: u16 = 30621;
|
||||
const MESH_STATUS_D_TAG: &str = "sprout-relay-mesh";
|
||||
const MESH_STATUS_TYPE: &str = "sprout-mesh-status";
|
||||
|
||||
fn relay_url() -> String {
|
||||
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
|
||||
}
|
||||
|
||||
/// Load a relay identity from an env-provided nsec. Returns `None` (and prints
|
||||
/// why) when the fixture is absent, so the caller skips rather than running
|
||||
/// against a `Keys::generate()` identity whose membership is undefined —
|
||||
/// asserting "member sees status" against a random key is the bug Perci caught.
|
||||
fn keys_from_env(var: &str) -> Option<Keys> {
|
||||
match std::env::var(var) {
|
||||
Ok(nsec) if !nsec.trim().is_empty() => match Keys::parse(nsec.trim()) {
|
||||
Ok(keys) => Some(keys),
|
||||
Err(e) => panic!("{var} is set but not a valid nsec/hex secret key: {e}"),
|
||||
},
|
||||
_ => {
|
||||
eprintln!(
|
||||
"SKIP: {var} not set — provision a relay {} identity and re-run (see module docs)",
|
||||
if var.contains("MEMBER") {
|
||||
"member"
|
||||
} else {
|
||||
"non-member"
|
||||
}
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sub_id(name: &str) -> String {
|
||||
format!("e2e-mesh-{name}-{}", uuid::Uuid::new_v4().simple())
|
||||
}
|
||||
|
||||
fn mesh_status_filter() -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::Custom(KIND_MESH_LLM_RELAY_STATUS))
|
||||
.identifier(MESH_STATUS_D_TAG)
|
||||
}
|
||||
|
||||
// ── (1) member reads the relay-signed status, with dial pointer, no secrets ──
|
||||
|
||||
/// Assertion 1: an authenticated relay member can REQ the relay-signed
|
||||
/// kind:30621 status event; its content carries the sanitized projection
|
||||
/// (mesh/models/serveTargets with EndpointAddr dial pointers) and NO secrets
|
||||
/// (no invite-secret, no local paths, no raw runtime object).
|
||||
///
|
||||
/// Requires a mesh-enabled relay that has published at least one status event.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn trust_member_reads_mesh_status() {
|
||||
let url = relay_url();
|
||||
let Some(member) = keys_from_env("MEMBER_NSEC") else {
|
||||
return;
|
||||
};
|
||||
let mut client = SproutTestClient::connect(&url, &member)
|
||||
.await
|
||||
.expect("member connect+auth");
|
||||
|
||||
let sid = sub_id("member-read");
|
||||
client
|
||||
.subscribe(&sid, vec![mesh_status_filter()])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
let events = client
|
||||
.collect_until_eose(&sid, Duration::from_secs(10))
|
||||
.await
|
||||
.expect("collect");
|
||||
|
||||
let status = events
|
||||
.iter()
|
||||
.find(|e| e.kind == Kind::Custom(KIND_MESH_LLM_RELAY_STATUS))
|
||||
.expect("a member must see at least one kind:30621 status event");
|
||||
|
||||
// Relay-signed (the relay keypair, not this member).
|
||||
assert_ne!(
|
||||
status.pubkey,
|
||||
member.public_key(),
|
||||
"status must be relay-signed, not member-signed"
|
||||
);
|
||||
|
||||
let content: serde_json::Value =
|
||||
serde_json::from_str(&status.content).expect("content is JSON");
|
||||
assert_eq!(content["type"], MESH_STATUS_TYPE, "type discriminator");
|
||||
|
||||
// Dial pointer present (EndpointAddr is connectivity, not a secret).
|
||||
let targets = content["serveTargets"]
|
||||
.as_array()
|
||||
.expect("serveTargets array");
|
||||
if let Some(t) = targets.first() {
|
||||
assert!(
|
||||
t.get("endpointAddr").is_some(),
|
||||
"serve target carries its EndpointAddr dial pointer"
|
||||
);
|
||||
}
|
||||
|
||||
// No secrets / no local-machine leakage in the published projection.
|
||||
let raw = status.content.to_lowercase();
|
||||
for forbidden in [
|
||||
"nsec",
|
||||
"secret",
|
||||
"/users/",
|
||||
"/home/",
|
||||
"runtime_dir",
|
||||
"local_path",
|
||||
] {
|
||||
assert!(
|
||||
!raw.contains(forbidden),
|
||||
"published status must not leak `{forbidden}`"
|
||||
);
|
||||
}
|
||||
|
||||
client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
// ── (2) non-member read denied ───────────────────────────────────────────────
|
||||
|
||||
/// Assertion 2: a valid Nostr identity that is NOT a relay member gets nothing
|
||||
/// back for a kind:30621 REQ — membership gates the read.
|
||||
///
|
||||
/// Requires a relay with `SPROUT_REQUIRE_RELAY_MEMBERSHIP=true` and a published
|
||||
/// status event that members can see (paired with assertion 1).
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn trust_nonmember_read_denied() {
|
||||
let url = relay_url();
|
||||
let Some(stranger) = keys_from_env("STRANGER_NSEC") else {
|
||||
return;
|
||||
};
|
||||
let mut client = match SproutTestClient::connect(&url, &stranger).await {
|
||||
Ok(c) => c,
|
||||
// A closed relay may refuse NIP-42 auth for a non-member outright —
|
||||
// that is also a valid "denied" outcome.
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let sid = sub_id("stranger-read");
|
||||
client
|
||||
.subscribe(&sid, vec![mesh_status_filter()])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
let events = client
|
||||
.collect_until_eose(&sid, Duration::from_secs(10))
|
||||
.await
|
||||
.expect("collect");
|
||||
|
||||
let leaked = events
|
||||
.iter()
|
||||
.any(|e| e.kind == Kind::Custom(KIND_MESH_LLM_RELAY_STATUS));
|
||||
assert!(
|
||||
!leaked,
|
||||
"non-member must NOT receive kind:30621 mesh status"
|
||||
);
|
||||
|
||||
client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
// ── (4) the demo: B's agent completes a chat against A's model over the mesh ──
|
||||
|
||||
/// Assertion 4 (the headline demo): with desktop A serving a model and desktop
|
||||
/// B running a mesh client + a launched sprout-agent pointed at B's local
|
||||
/// `:9337/v1`, a chat completion returns a non-empty response routed over the
|
||||
/// mesh to A's GPU.
|
||||
///
|
||||
/// This needs two live mesh nodes + a small served model — runbook only, never
|
||||
/// in default CI. Left as a documented, compiling placeholder so the acceptance
|
||||
/// matrix is executable code, not prose; wire the live harness when M1 lands.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn live_agent_completes_chat_over_mesh() {
|
||||
// RUNBOOK (M1 hardware): see module docs.
|
||||
// A: Share compute → serve a small model. B: mesh client up on :9337.
|
||||
// Point this test at B's local OpenAI endpoint via MESH_OPENAI_BASE
|
||||
// (e.g. http://127.0.0.1:9337/v1). When set, we drive a real completion
|
||||
// over the mesh and assert non-empty output — no endpoint, no silent pass.
|
||||
let Ok(base) = std::env::var("MESH_OPENAI_BASE") else {
|
||||
eprintln!(
|
||||
"SKIP: MESH_OPENAI_BASE not set — needs a live mesh client endpoint (see module docs)"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let base = base.trim_end_matches('/').to_string();
|
||||
let http = reqwest::Client::new();
|
||||
|
||||
// Resolve the served model id (the node assigns its own, not our ref).
|
||||
let models: serde_json::Value = http
|
||||
.get(format!("{base}/models"))
|
||||
.send()
|
||||
.await
|
||||
.expect("GET /models")
|
||||
.json()
|
||||
.await
|
||||
.expect("/models JSON");
|
||||
let model_id = models["data"][0]["id"]
|
||||
.as_str()
|
||||
.expect("at least one model served over the mesh")
|
||||
.to_string();
|
||||
|
||||
let resp: serde_json::Value = http
|
||||
.post(format!("{base}/chat/completions"))
|
||||
.json(&serde_json::json!({
|
||||
"model": model_id,
|
||||
"messages": [{"role": "user", "content": "Reply with exactly one word: PONG"}],
|
||||
"max_tokens": 512,
|
||||
"temperature": 0.0,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("POST /chat/completions over mesh")
|
||||
.json()
|
||||
.await
|
||||
.expect("completion JSON");
|
||||
|
||||
let content = resp["choices"][0]["message"]["content"]
|
||||
.as_str()
|
||||
.expect("completion has message content");
|
||||
assert!(
|
||||
!content.trim().is_empty(),
|
||||
"chat completion over the mesh must return non-empty content"
|
||||
);
|
||||
}
|
||||
|
||||
// ── (6) split variant ────────────────────────────────────────────────────────
|
||||
|
||||
/// Assertion 6 (split): a model too large for one node + two serve nodes in the
|
||||
/// same mesh → mesh auto-splits → the same chat (assertion 4) completes via the
|
||||
/// split route. Auto-split is mesh runtime behavior (no Sprout code); this row
|
||||
/// only verifies two serve desktops in one mesh produce a working split.
|
||||
///
|
||||
/// Runbook only — needs a known too-large-for-one-node fixture + 2 serve nodes.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn live_split_model_completes() {
|
||||
// RUNBOOK: A + C both serve the oversized model into the same mesh; B's
|
||||
// agent completes a chat; mesh elects a split topology (>=2 stage participants).
|
||||
// Genuinely multi-node — cannot be automated single-process. Left unwired
|
||||
// so `--ignored` can never report it green without a real split harness.
|
||||
panic!("live_split_model_completes: not implemented — runbook only (see module docs)");
|
||||
}
|
||||
47
deny.toml
47
deny.toml
@@ -3,6 +3,9 @@ ignore = [
|
||||
# instant 0.1.13 — unmaintained crate. Transitive dep: nostr → instant.
|
||||
# Will be resolved when nostr crate updates its dependencies.
|
||||
{ id = "RUSTSEC-2024-0384", reason = "transitive dep via nostr; no upstream fix available" },
|
||||
# paste 1.0.15 — unmaintained. Transitive dep: mesh-llm → iroh → netlink-* → paste.
|
||||
# No safe upgrade available; tracked for upstream (iroh/netlink) replacement.
|
||||
{ id = "RUSTSEC-2024-0436", reason = "transitive dep via mesh-llm → iroh → netlink; no upstream fix available" },
|
||||
]
|
||||
|
||||
[licenses]
|
||||
@@ -22,9 +25,53 @@ allow = [
|
||||
"MITNFA",
|
||||
"MPL-2.0",
|
||||
"BSL-1.0",
|
||||
"Unlicense",
|
||||
# bzip2/libbzip2's permissive BSD-like license. New via desktop zip/bzip2
|
||||
# transitive deps; compatible with Apache-2.0 distribution.
|
||||
"bzip2-1.0.6",
|
||||
]
|
||||
confidence-threshold = 0.8
|
||||
|
||||
# mesh-llm workspace crates (pinned git dep) omit a per-crate `license` field in
|
||||
# their manifests, so cargo-deny reports them as unlicensed. The mesh-llm repo is
|
||||
# licensed "MIT OR Apache-2.0" (workspace Cargo.toml + top-level LICENSE = Apache-2.0);
|
||||
# clarify each pulled-in member to that expression. Remove once mesh sets the field
|
||||
# upstream (filed).
|
||||
[[licenses.clarify]]
|
||||
crate = "mesh-llm-config"
|
||||
expression = "MIT OR Apache-2.0"
|
||||
license-files = []
|
||||
|
||||
[[licenses.clarify]]
|
||||
crate = "mesh-llm-gpu-bench"
|
||||
expression = "MIT OR Apache-2.0"
|
||||
license-files = []
|
||||
|
||||
[[licenses.clarify]]
|
||||
crate = "mesh-llm-host-runtime"
|
||||
expression = "MIT OR Apache-2.0"
|
||||
license-files = []
|
||||
|
||||
[[licenses.clarify]]
|
||||
crate = "mesh-llm-plugin"
|
||||
expression = "MIT OR Apache-2.0"
|
||||
license-files = []
|
||||
|
||||
[[licenses.clarify]]
|
||||
crate = "mesh-llm-system"
|
||||
expression = "MIT OR Apache-2.0"
|
||||
license-files = []
|
||||
|
||||
[[licenses.clarify]]
|
||||
crate = "mesh-mixture-of-agents"
|
||||
expression = "MIT OR Apache-2.0"
|
||||
license-files = []
|
||||
|
||||
[[licenses.clarify]]
|
||||
crate = "sprout-desktop"
|
||||
expression = "Apache-2.0"
|
||||
license-files = []
|
||||
|
||||
[licenses.private]
|
||||
ignore = true
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ export default defineConfig({
|
||||
"**/profile.spec.ts",
|
||||
"**/tokens.spec.ts",
|
||||
"**/persona-env-vars.spec.ts",
|
||||
"**/mesh-compute.spec.ts",
|
||||
],
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
|
||||
@@ -51,10 +51,10 @@ const overrides = new Map([
|
||||
["src/shared/api/relayClientSession.ts", 1040], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + ConnectionState plumbing & stall-watchdog wiring for half-open WS detection (Warp orange-icon case) + terminal session latch (auth rejection no longer racing back to reconnecting) — emitter + watchdog + reconnect policy logic extracted to relayConnectionStateEmitter.ts / relayStallWatchdog.ts / relayReconnectPolicy.ts
|
||||
["src-tauri/src/migration.rs", 1010], // worktree shared-agent-data symlink sync (SHARED_AGENT_FILES + SHARED_AGENT_DIRS symlink-to-canonical + sibling pack migration) + mcp_command provider reconciliation + persona_pack_path reconciliation + tests
|
||||
["src-tauri/src/commands/media.rs", 730], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg via resolve_command, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests
|
||||
["src-tauri/src/commands/agents.rs", 881], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field + NIP-OA auth_tag in deploy payload
|
||||
["src-tauri/src/commands/agents.rs", 900], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field + NIP-OA auth_tag in deploy payload + relay-mesh client preflight (start_local_agent_with_preflight + StartTarget split: async ensure outside store lock, then sync spawn under lock)
|
||||
["src-tauri/src/commands/messages.rs", 515], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ + edit_message media_tags param (Slack-style attachment-editable edits)
|
||||
["src-tauri/src/nostr_convert.rs", 1150], // 12 Nostr event→model converters (channels, profiles, members, notes, search, agents, relay members) + rank_user_search_results helper for NIP-50 user search + 33 unit tests
|
||||
["src-tauri/src/managed_agents/runtime.rs", 1330], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + persona/agent env_vars spawn merge (helper + tests now in env_vars.rs) + system-wide orphan sweep (proc_listallpids/proc on macOS, /proc on Linux) + SPROUT_MANAGED_AGENT env marker check (KERN_PROCARGS2 on macOS, /proc/environ on Linux), instance-scoped by app identifier so coexisting Sprout instances don't reap each other's agents
|
||||
["src-tauri/src/managed_agents/runtime.rs", 1385], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + persona/agent env_vars spawn merge (helper + tests now in env_vars.rs) + system-wide orphan sweep (proc_listallpids/proc on macOS, /proc on Linux) + SPROUT_MANAGED_AGENT env marker check (KERN_PROCARGS2 on macOS, /proc/environ on Linux), instance-scoped by app identifier so coexisting Sprout instances don't reap each other's agents + relay_mesh_model_id detector (mesh preset-env match) + tests
|
||||
["src-tauri/src/managed_agents/discovery.rs", 680], // KNOWN_ACP_PROVIDERS catalog + resolve_command cache + login_shell_path + classify_provider (four-state: Available/AdapterMissing/CliMissing/NotInstalled) + discover_acp_providers with dynamic install_hint + known_acp_provider/known_acp_provider_exact + normalize_agent_args + 15 unit tests
|
||||
["src-tauri/src/managed_agents/types.rs", 745], // ManagedAgentRecord/Summary + Create/Update request structs + AcpProviderCatalogEntry + InstallRuntimeResult + RespondTo enum + validate_respond_to_allowlist + tests + persona/agent env_vars field
|
||||
["src-tauri/src/managed_agents/backend.rs", 700], // provider IPC, validation, discovery, binary resolution + tests + redact_secrets_with for user env values + env_secrets_from_request + redact_env_values_in (shared with model discovery)
|
||||
@@ -81,7 +81,7 @@ const overrides = new Map([
|
||||
["src-tauri/src/huddle/tts.rs", 1380], // TTS pipeline + session warmup + cancel/shutdown handling + apply_fade_out (fade-out only — leading fade removed 2026-05-18 after onset-attenuation regression measured in examples/pocket_onset_probe.rs) + FIRST_APPEND_LEAD_IN_SAMPLES + build_sentence_append_plan (pure helper enforcing the lead-in fires exactly once per utterance, not per sentence — see lead_in_pad_fires_exactly_once_per_utterance regression test) + normalize_for_playback (per-sentence peak normalization to -3 dBFS ceiling with MAX_GAIN cap) + 30 unit tests (18 interrupt + 5 fade-out + 1 first-append-lead-in + 3 build-sentence-append-plan + 6 normalize)
|
||||
["src-tauri/src/relay.rs", 510], // +4 lines for NIP-OA auth tag injection in profile sync (build_profile_event) + verification test
|
||||
["src-tauri/src/commands/pairing.rs", 600], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + NIP-43 probe + event parsing helpers
|
||||
["src-tauri/src/lib.rs", 770], // +4 lines for PairingHandle managed state + 3 pairing command registrations + parse_message_deep_link helper extracted with 6 unit tests covering empty-param filter regression + mod migration + sync_shared_agent_data/reconcile_provider_mcp_commands/reconcile_persona_pack_paths calls on launch + SIGINT/SIGTERM/SIGHUP signal handlers for agent process cleanup
|
||||
["src-tauri/src/lib.rs", 780], // + mesh command registrations + PairingHandle managed state + 3 pairing command registrations + parse_message_deep_link helper extracted with 6 unit tests covering empty-param filter regression + mod migration + sync_shared_agent_data/reconcile_provider_mcp_commands/reconcile_persona_pack_paths calls on launch + SIGINT/SIGTERM/SIGHUP signal handlers for agent process cleanup
|
||||
["src/shared/api/tauri.ts", 1212], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field + relay member API functions (list/get/add/remove/change-role) + prevent sleep + AcpProviderCatalogEntry raw types + fromRawAcpProviderCatalogEntry converter + installAcpRuntime
|
||||
]);
|
||||
|
||||
|
||||
4941
desktop/src-tauri/Cargo.lock
generated
4941
desktop/src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@ windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
|
||||
|
||||
[dependencies]
|
||||
atomic-write-file = "0.3"
|
||||
anyhow = "1"
|
||||
dirs = "6"
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-deep-link = "2"
|
||||
@@ -55,6 +56,7 @@ url = "2"
|
||||
sprout-core = { path = "../../crates/sprout-core" }
|
||||
sprout-persona = { path = "../../crates/sprout-persona" }
|
||||
sprout-sdk = { path = "../../crates/sprout-sdk" }
|
||||
mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "bd16da4955a9dceee8f1e0a02c5814944f4e12a5", package = "mesh-llm-sdk", default-features = false, features = ["client", "serve"] }
|
||||
base64 = "0.22"
|
||||
sha2 = "0.11"
|
||||
tar = "0.4"
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::{
|
||||
|
||||
use nostr::{Keys, ToBech32};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
use crate::huddle::HuddleState;
|
||||
use crate::managed_agents::ManagedAgentProcess;
|
||||
@@ -33,6 +34,8 @@ pub struct AppState {
|
||||
pub media_proxy_port: AtomicU16,
|
||||
/// IOKit power assertion state — prevents idle sleep while agents run.
|
||||
pub prevent_sleep: Arc<Mutex<crate::prevent_sleep::PreventSleepState>>,
|
||||
/// In-process mesh-llm node started by Sprout Desktop.
|
||||
pub mesh_llm_runtime: AsyncMutex<Option<crate::mesh_llm::DesktopMeshRuntime>>,
|
||||
}
|
||||
|
||||
pub fn build_app_state() -> AppState {
|
||||
@@ -78,6 +81,7 @@ pub fn build_app_state() -> AppState {
|
||||
prevent_sleep: Arc::new(Mutex::new(
|
||||
crate::prevent_sleep::PreventSleepState::default(),
|
||||
)),
|
||||
mesh_llm_runtime: AsyncMutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,13 @@ use crate::{
|
||||
build_managed_agent_summary, discover_provider_candidates, ensure_persona_is_active,
|
||||
find_managed_agent_mut, invoke_provider, load_managed_agents, load_personas,
|
||||
managed_agent_avatar_url, managed_agent_log_path, managed_agents_base_dir,
|
||||
normalize_agent_args, provider_deploy, read_log_tail, resolve_provider_binary,
|
||||
save_managed_agents, start_managed_agent_process, stop_managed_agent_process,
|
||||
sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind,
|
||||
BackendProviderInfo, CreateManagedAgentRequest, CreateManagedAgentResponse,
|
||||
ManagedAgentLogResponse, ManagedAgentRecord, ManagedAgentSummary, DEFAULT_ACP_COMMAND,
|
||||
DEFAULT_AGENT_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
|
||||
DEFAULT_MCP_COMMAND,
|
||||
normalize_agent_args, provider_deploy, read_log_tail, relay_mesh_model_id,
|
||||
resolve_provider_binary, save_managed_agents, start_managed_agent_process,
|
||||
stop_managed_agent_process, sync_managed_agent_processes, try_regenerate_nest,
|
||||
validate_provider_config, BackendKind, BackendProviderInfo, CreateManagedAgentRequest,
|
||||
CreateManagedAgentResponse, ManagedAgentLogResponse, ManagedAgentRecord,
|
||||
ManagedAgentSummary, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_COMMAND, DEFAULT_AGENT_PARALLELISM,
|
||||
DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, DEFAULT_MCP_COMMAND,
|
||||
},
|
||||
relay::{relay_ws_url_with_override, sync_managed_agent_profile},
|
||||
util::now_iso,
|
||||
@@ -27,6 +27,64 @@ fn workspace_owner_hex(state: &AppState) -> Result<String, String> {
|
||||
Ok(keys.public_key().to_hex())
|
||||
}
|
||||
|
||||
async fn ensure_relay_mesh_for_record(
|
||||
state: &AppState,
|
||||
record: &ManagedAgentRecord,
|
||||
) -> Result<(), String> {
|
||||
let Some(model_id) = relay_mesh_model_id(record) else {
|
||||
return Ok(());
|
||||
};
|
||||
crate::commands::mesh_llm::ensure_client_node_for_model(state, model_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start_local_agent_with_preflight(
|
||||
app: &AppHandle,
|
||||
state: &AppState,
|
||||
pubkey: &str,
|
||||
owner_hex: &str,
|
||||
) -> Result<ManagedAgentSummary, String> {
|
||||
let record_snapshot = {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let records = load_managed_agents(app)?;
|
||||
records
|
||||
.iter()
|
||||
.find(|record| record.pubkey == pubkey)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("agent {pubkey} not found"))?
|
||||
};
|
||||
|
||||
if record_snapshot.backend != BackendKind::Local {
|
||||
return Err(format!("agent {pubkey} is not a local agent"));
|
||||
}
|
||||
|
||||
ensure_relay_mesh_for_record(state, &record_snapshot).await?;
|
||||
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut records = load_managed_agents(app)?;
|
||||
let mut runtimes = state
|
||||
.managed_agent_processes
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let record = find_managed_agent_mut(&mut records, pubkey)?;
|
||||
if record.backend != BackendKind::Local {
|
||||
return Err(format!("agent {pubkey} is no longer a local agent"));
|
||||
}
|
||||
start_managed_agent_process(app, record, &mut runtimes, Some(owner_hex))?;
|
||||
save_managed_agents(app, &records)?;
|
||||
let record = records
|
||||
.iter()
|
||||
.find(|record| record.pubkey == pubkey)
|
||||
.ok_or_else(|| format!("agent {pubkey} not found"))?;
|
||||
build_managed_agent_summary(app, record, &runtimes)
|
||||
}
|
||||
|
||||
/// Build the standard agent JSON payload for provider deploy calls.
|
||||
///
|
||||
/// Fails closed if the agent points at a `persona_id` we can't load — persona
|
||||
@@ -287,8 +345,8 @@ pub async fn create_managed_agent(
|
||||
Some(tag)
|
||||
};
|
||||
|
||||
// ── Phase 3: save record and optionally spawn (sync lock) ─────────────────
|
||||
let (agent, spawn_error) = {
|
||||
// ── Phase 3: save record (sync lock) ───────────────────────────────────────
|
||||
let agent = {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
@@ -438,17 +496,6 @@ pub async fn create_managed_agent(
|
||||
|
||||
records.push(record);
|
||||
|
||||
let mut spawn_error = None;
|
||||
if input.spawn_after_create && input.backend == BackendKind::Local {
|
||||
let record = find_managed_agent_mut(&mut records, &pubkey)?;
|
||||
if let Err(error) =
|
||||
start_managed_agent_process(&app, record, &mut runtimes, Some(&owner_hex))
|
||||
{
|
||||
record.updated_at = now_iso();
|
||||
record.last_error = Some(error.clone());
|
||||
spawn_error = Some(error);
|
||||
}
|
||||
}
|
||||
save_managed_agents(&app, &records)?;
|
||||
|
||||
let record = records
|
||||
@@ -457,7 +504,38 @@ pub async fn create_managed_agent(
|
||||
.ok_or_else(|| "created agent disappeared unexpectedly".to_string())?;
|
||||
let agent = build_managed_agent_summary(&app, record, &runtimes)?;
|
||||
|
||||
(agent, spawn_error)
|
||||
agent
|
||||
};
|
||||
|
||||
// ── Phase 3b: local spawn (async preflight outside store lock) ───────────
|
||||
let mut spawn_error = None;
|
||||
let agent = if input.spawn_after_create && input.backend == BackendKind::Local {
|
||||
match start_local_agent_with_preflight(&app, &state, &pubkey, &owner_hex).await {
|
||||
Ok(agent) => agent,
|
||||
Err(error) => {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut records = load_managed_agents(&app)?;
|
||||
let runtimes = state
|
||||
.managed_agent_processes
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let record = find_managed_agent_mut(&mut records, &pubkey)?;
|
||||
record.updated_at = now_iso();
|
||||
record.last_error = Some(error.clone());
|
||||
save_managed_agents(&app, &records)?;
|
||||
spawn_error = Some(error);
|
||||
let record = records
|
||||
.iter()
|
||||
.find(|record| record.pubkey == pubkey)
|
||||
.ok_or_else(|| "created agent disappeared unexpectedly".to_string())?;
|
||||
build_managed_agent_summary(&app, record, &runtimes)?
|
||||
}
|
||||
}
|
||||
} else {
|
||||
agent
|
||||
};
|
||||
|
||||
try_regenerate_nest(&app);
|
||||
@@ -566,8 +644,17 @@ pub async fn start_managed_agent(
|
||||
// Snapshot the workspace owner pubkey for the legacy auth_tag fallback.
|
||||
// Read outside the records lock to keep lock ordering simple.
|
||||
let owner_hex = workspace_owner_hex(&state)?;
|
||||
// Collect backend info and handle local vs provider under lock.
|
||||
let (backend, cached_binary_path, agent_json) = {
|
||||
enum StartTarget {
|
||||
Local,
|
||||
Provider {
|
||||
backend: BackendKind,
|
||||
cached_binary_path: Option<String>,
|
||||
agent_json: serde_json::Value,
|
||||
},
|
||||
}
|
||||
|
||||
// Collect backend info under lock; async preflight/spawn happens below.
|
||||
let target = {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
@@ -585,55 +672,56 @@ pub async fn start_managed_agent(
|
||||
let record = find_managed_agent_mut(&mut records, &pubkey)?;
|
||||
|
||||
if record.backend == BackendKind::Local {
|
||||
// Local: spawn in-process and return immediately.
|
||||
start_managed_agent_process(&app, record, &mut runtimes, Some(&owner_hex))?;
|
||||
save_managed_agents(&app, &records)?;
|
||||
StartTarget::Local
|
||||
} else {
|
||||
StartTarget::Provider {
|
||||
backend: record.backend.clone(),
|
||||
cached_binary_path: record.provider_binary_path.clone(),
|
||||
agent_json: build_deploy_payload(&app, record)?,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match target {
|
||||
StartTarget::Local => {
|
||||
start_local_agent_with_preflight(&app, &state, &pubkey, &owner_hex).await
|
||||
}
|
||||
StartTarget::Provider {
|
||||
backend: BackendKind::Provider { id, config },
|
||||
cached_binary_path,
|
||||
agent_json,
|
||||
} => {
|
||||
deploy_to_provider(
|
||||
&app,
|
||||
&state,
|
||||
&pubkey,
|
||||
&id,
|
||||
&config,
|
||||
agent_json,
|
||||
cached_binary_path.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Return updated summary.
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let records = load_managed_agents(&app)?;
|
||||
let runtimes = state
|
||||
.managed_agent_processes
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let record = records
|
||||
.iter()
|
||||
.find(|r| r.pubkey == pubkey)
|
||||
.ok_or_else(|| format!("agent {pubkey} not found"))?;
|
||||
return build_managed_agent_summary(&app, record, &runtimes);
|
||||
build_managed_agent_summary(&app, record, &runtimes)
|
||||
}
|
||||
|
||||
let payload = build_deploy_payload(&app, record)?;
|
||||
(
|
||||
record.backend.clone(),
|
||||
record.provider_binary_path.clone(),
|
||||
payload,
|
||||
)
|
||||
};
|
||||
|
||||
// Provider backend: deploy via shared helper (async, outside lock).
|
||||
if let BackendKind::Provider { ref id, ref config } = backend {
|
||||
deploy_to_provider(
|
||||
&app,
|
||||
&state,
|
||||
&pubkey,
|
||||
id,
|
||||
config,
|
||||
agent_json,
|
||||
cached_binary_path.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Return updated summary.
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let records = load_managed_agents(&app)?;
|
||||
let runtimes = state
|
||||
.managed_agent_processes
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let record = records
|
||||
.iter()
|
||||
.find(|r| r.pubkey == pubkey)
|
||||
.ok_or_else(|| format!("agent {pubkey} not found"))?;
|
||||
return build_managed_agent_summary(&app, record, &runtimes);
|
||||
StartTarget::Provider { backend, .. } => Err(format!(
|
||||
"agent {pubkey} has unsupported backend kind: {backend:?}"
|
||||
)),
|
||||
}
|
||||
|
||||
Err(format!("agent {pubkey} has unsupported backend kind"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
229
desktop/src-tauri/src/commands/mesh_llm.rs
Normal file
229
desktop/src-tauri/src/commands/mesh_llm.rs
Normal file
@@ -0,0 +1,229 @@
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use nostr::{EventBuilder, JsonUtil, Kind, Tag};
|
||||
use reqwest::Method;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::{app_state::AppState, mesh_llm, relay};
|
||||
|
||||
pub type CmdResult<T> = Result<T, String>;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mesh_availability(
|
||||
state: State<'_, AppState>,
|
||||
) -> CmdResult<mesh_llm::MeshAvailability> {
|
||||
match relay::query_relay(&state, &[mesh_llm::mesh_status_filter()]).await {
|
||||
Ok(events) => Ok(mesh_llm::availability_from_events(events)),
|
||||
Err(error) => Ok(mesh_llm::MeshAvailability::unavailable(error)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mesh_start_node(
|
||||
_app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
mut request: mesh_llm::StartMeshNodeRequest,
|
||||
) -> CmdResult<mesh_llm::MeshNodeStatus> {
|
||||
let mut runtime = state.mesh_llm_runtime.lock().await;
|
||||
if runtime.is_some() {
|
||||
return Err("mesh node is already running".to_string());
|
||||
}
|
||||
|
||||
hydrate_private_relay_config(&state, &mut request).await?;
|
||||
|
||||
let started = mesh_llm::DesktopMeshRuntime::start(request)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let status = started
|
||||
.status()
|
||||
.await
|
||||
.map_err(|error| format!("mesh node started but status probe failed: {error}"))?;
|
||||
*runtime = Some(started);
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mesh_ensure_client_node(
|
||||
state: State<'_, AppState>,
|
||||
request: mesh_llm::EnsureMeshClientRequest,
|
||||
) -> CmdResult<mesh_llm::MeshNodeStatus> {
|
||||
ensure_client_node_for_model(&state, request.model_id).await
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_client_node_for_model(
|
||||
state: &AppState,
|
||||
model_id: impl AsRef<str>,
|
||||
) -> CmdResult<mesh_llm::MeshNodeStatus> {
|
||||
let requested_model = model_id.as_ref().trim();
|
||||
if requested_model.is_empty() {
|
||||
return Err("modelId is required".to_string());
|
||||
}
|
||||
|
||||
{
|
||||
let runtime = state.mesh_llm_runtime.lock().await;
|
||||
if let Some(runtime) = runtime.as_ref() {
|
||||
let status = runtime.status().await.map_err(|error| error.to_string())?;
|
||||
return match status.mode {
|
||||
Some(mesh_llm::MeshNodeMode::Client) => Ok(status),
|
||||
Some(mesh_llm::MeshNodeMode::Serve) => Err(
|
||||
"this desktop is currently sharing compute; stop sharing before using relay mesh as a client"
|
||||
.to_string(),
|
||||
),
|
||||
None => Ok(status),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let availability = match relay::query_relay(&state, &[mesh_llm::mesh_status_filter()]).await {
|
||||
Ok(events) => mesh_llm::availability_from_events(events),
|
||||
Err(error) => return Err(format!("failed to read relay mesh status: {error}")),
|
||||
};
|
||||
if !availability.available {
|
||||
return Err(availability
|
||||
.reason
|
||||
.unwrap_or_else(|| "relay mesh is not available".to_string()));
|
||||
}
|
||||
let target = availability
|
||||
.serve_targets
|
||||
.iter()
|
||||
.find(|target| target.model_id == requested_model)
|
||||
.ok_or_else(|| format!("relay mesh has no serve target for model {requested_model}"))?;
|
||||
|
||||
let mut start = mesh_llm::StartMeshNodeRequest {
|
||||
mode: mesh_llm::MeshNodeMode::Client,
|
||||
model_id: None,
|
||||
max_vram_gb: None,
|
||||
join_token: Some(target.endpoint_addr.clone()),
|
||||
iroh_relay_url: None,
|
||||
iroh_relay_auth: None,
|
||||
};
|
||||
hydrate_private_relay_config(&state, &mut start).await?;
|
||||
|
||||
let mut runtime = state.mesh_llm_runtime.lock().await;
|
||||
if runtime.is_some() {
|
||||
return Err("mesh node changed while starting relay mesh client".to_string());
|
||||
}
|
||||
let started = mesh_llm::DesktopMeshRuntime::start(start)
|
||||
.await
|
||||
.map_err(|error| format!("mesh client failed to start: {error}"))?;
|
||||
let status = started
|
||||
.status()
|
||||
.await
|
||||
.map_err(|error| format!("mesh client started but status probe failed: {error}"))?;
|
||||
*runtime = Some(started);
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
async fn hydrate_private_relay_config(
|
||||
state: &AppState,
|
||||
request: &mut mesh_llm::StartMeshNodeRequest,
|
||||
) -> Result<(), String> {
|
||||
if request.iroh_relay_url.is_none() {
|
||||
request.iroh_relay_url = Some(fetch_iroh_relay_url(state).await?);
|
||||
}
|
||||
if request.iroh_relay_auth.is_none() {
|
||||
let relay = request
|
||||
.iroh_relay_url
|
||||
.as_deref()
|
||||
.ok_or_else(|| "relay did not advertise iroh_relay_url".to_string())?;
|
||||
request.iroh_relay_auth = Some(build_iroh_relay_bearer(state, relay)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_iroh_relay_url(state: &AppState) -> Result<String, String> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Nip11Info {
|
||||
iroh_relay_url: Option<String>,
|
||||
}
|
||||
|
||||
let url = relay::relay_api_base_url_with_override(state);
|
||||
let response = state
|
||||
.http_client
|
||||
.get(&url)
|
||||
.header("Accept", "application/nostr+json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("failed to fetch relay NIP-11: {error}"))?;
|
||||
if !response.status().is_success() {
|
||||
return Err(relay::relay_error_message(response).await);
|
||||
}
|
||||
let info = response
|
||||
.json::<Nip11Info>()
|
||||
.await
|
||||
.map_err(|error| format!("failed to parse relay NIP-11: {error}"))?;
|
||||
info.iroh_relay_url
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| "relay NIP-11 does not advertise iroh_relay_url".to_string())
|
||||
}
|
||||
|
||||
fn build_iroh_relay_bearer(state: &AppState, relay_url: &str) -> Result<String, String> {
|
||||
let canonical = canonical_iroh_relay_auth_url(relay_url)?;
|
||||
let tags = vec![
|
||||
Tag::parse(vec!["u", canonical.as_str()])
|
||||
.map_err(|error| format!("url tag failed: {error}"))?,
|
||||
Tag::parse(vec!["method", Method::GET.as_str()])
|
||||
.map_err(|error| format!("method tag failed: {error}"))?,
|
||||
];
|
||||
let keys = state.keys.lock().map_err(|error| error.to_string())?;
|
||||
let event = EventBuilder::new(Kind::HttpAuth, "")
|
||||
.tags(tags)
|
||||
.sign_with_keys(&keys)
|
||||
.map_err(|error| format!("sign failed: {error}"))?;
|
||||
Ok(BASE64.encode(event.as_json().as_bytes()))
|
||||
}
|
||||
|
||||
fn canonical_iroh_relay_auth_url(relay_url: &str) -> Result<String, String> {
|
||||
let mut parsed = url::Url::parse(relay_url)
|
||||
.map_err(|error| format!("invalid iroh relay URL {relay_url:?}: {error}"))?;
|
||||
parsed.set_query(None);
|
||||
parsed.set_fragment(None);
|
||||
let mut path = parsed.path().trim_end_matches('/').to_string();
|
||||
if !path.ends_with("/relay") {
|
||||
if path.is_empty() {
|
||||
path = "/relay".to_string();
|
||||
} else {
|
||||
path.push_str("/relay");
|
||||
}
|
||||
}
|
||||
parsed.set_path(&path);
|
||||
Ok(parsed.to_string().trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mesh_stop_node(state: State<'_, AppState>) -> CmdResult<mesh_llm::MeshNodeStatus> {
|
||||
let runtime = state.mesh_llm_runtime.lock().await.take();
|
||||
if let Some(runtime) = runtime {
|
||||
runtime.stop().await.map_err(|error| error.to_string())?;
|
||||
}
|
||||
Ok(mesh_llm::stopped_status())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mesh_node_status(state: State<'_, AppState>) -> CmdResult<mesh_llm::MeshNodeStatus> {
|
||||
let runtime = state.mesh_llm_runtime.lock().await;
|
||||
match runtime.as_ref() {
|
||||
Some(runtime) => runtime.status().await.map_err(|error| error.to_string()),
|
||||
None => Ok(mesh_llm::stopped_status()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mesh_installed_models(
|
||||
state: State<'_, AppState>,
|
||||
) -> CmdResult<Vec<mesh_llm::MeshModelOption>> {
|
||||
let runtime = state.mesh_llm_runtime.lock().await;
|
||||
if let Some(runtime) = runtime.as_ref() {
|
||||
return runtime
|
||||
.installed_models()
|
||||
.await
|
||||
.map_err(|error| error.to_string());
|
||||
}
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn mesh_agent_preset(
|
||||
request: mesh_llm::MeshAgentPresetRequest,
|
||||
) -> CmdResult<mesh_llm::MeshAgentPreset> {
|
||||
mesh_llm::agent_preset(request)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ mod identity_archive;
|
||||
mod media;
|
||||
mod media_download;
|
||||
mod media_transcode;
|
||||
mod mesh_llm;
|
||||
mod messages;
|
||||
pub mod pairing;
|
||||
mod personas;
|
||||
@@ -35,6 +36,7 @@ pub use identity::*;
|
||||
pub use identity_archive::*;
|
||||
pub use media::*;
|
||||
pub use media_download::*;
|
||||
pub use mesh_llm::*;
|
||||
pub use messages::*;
|
||||
pub use pairing::*;
|
||||
pub use personas::*;
|
||||
|
||||
@@ -4,6 +4,7 @@ mod events;
|
||||
mod huddle;
|
||||
mod managed_agents;
|
||||
mod media_proxy;
|
||||
mod mesh_llm;
|
||||
mod migration;
|
||||
mod models;
|
||||
pub mod nostr_convert;
|
||||
@@ -491,9 +492,9 @@ pub fn run() {
|
||||
|
||||
// Keep launch-time agent restoration off the synchronous setup path
|
||||
// so the frontend can mount and reveal the window promptly.
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(error) =
|
||||
restore_managed_agents_on_launch(&app_handle, shutdown_started.as_ref())
|
||||
restore_managed_agents_on_launch(&app_handle, shutdown_started.as_ref()).await
|
||||
{
|
||||
eprintln!("sprout-desktop: failed to restore managed agents: {error}");
|
||||
}
|
||||
@@ -579,6 +580,13 @@ pub fn run() {
|
||||
delete_managed_agent,
|
||||
get_managed_agent_log,
|
||||
get_agent_models,
|
||||
mesh_availability,
|
||||
mesh_start_node,
|
||||
mesh_ensure_client_node,
|
||||
mesh_stop_node,
|
||||
mesh_node_status,
|
||||
mesh_installed_models,
|
||||
mesh_agent_preset,
|
||||
update_managed_agent,
|
||||
discover_backend_providers,
|
||||
probe_backend_provider,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::{
|
||||
find_managed_agent_mut, kill_stale_tracked_processes, load_managed_agents, save_managed_agents,
|
||||
spawn_agent_child, sync_managed_agent_processes, BackendKind, ManagedAgentProcess,
|
||||
find_managed_agent_mut, kill_stale_tracked_processes, load_managed_agents, relay_mesh_model_id,
|
||||
save_managed_agents, spawn_agent_child, sync_managed_agent_processes, BackendKind,
|
||||
ManagedAgentProcess,
|
||||
};
|
||||
use crate::app_state::AppState;
|
||||
use crate::util;
|
||||
@@ -16,7 +17,7 @@ type AgentSpawnResult = (String, SpawnResult);
|
||||
/// A (under lock): sync process state, cleanup, collect agents to start
|
||||
/// B (no locks): resolve commands and spawn processes in parallel
|
||||
/// C (re-lock): write back PIDs and status to records on disk
|
||||
pub fn restore_managed_agents_on_launch(
|
||||
pub async fn restore_managed_agents_on_launch(
|
||||
app: &tauri::AppHandle,
|
||||
shutdown_started: &AtomicBool,
|
||||
) -> Result<(), String> {
|
||||
@@ -102,6 +103,24 @@ pub fn restore_managed_agents_on_launch(
|
||||
.ok()
|
||||
.map(|k| k.public_key().to_hex());
|
||||
|
||||
let mut mesh_preflight_failures = std::collections::HashSet::new();
|
||||
for record in &agents_to_start {
|
||||
let Some(model_id) = relay_mesh_model_id(record) else {
|
||||
continue;
|
||||
};
|
||||
if let Err(error) = crate::commands::ensure_client_node_for_model(&state, model_id).await {
|
||||
persist_restore_error(app, &state, &record.pubkey, error)?;
|
||||
mesh_preflight_failures.insert(record.pubkey.clone());
|
||||
}
|
||||
}
|
||||
let agents_to_start: Vec<_> = agents_to_start
|
||||
.into_iter()
|
||||
.filter(|record| !mesh_preflight_failures.contains(&record.pubkey))
|
||||
.collect();
|
||||
if agents_to_start.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ── Phase B (no locks): resolve commands and spawn processes in parallel ──
|
||||
let spawn_results: Vec<AgentSpawnResult> = std::thread::scope(|scope| {
|
||||
let owner_hex_ref = owner_hex.as_deref();
|
||||
@@ -163,3 +182,20 @@ pub fn restore_managed_agents_on_launch(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_restore_error(
|
||||
app: &tauri::AppHandle,
|
||||
state: &AppState,
|
||||
pubkey: &str,
|
||||
error: String,
|
||||
) -> Result<(), String> {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut records = load_managed_agents(app)?;
|
||||
let record = find_managed_agent_mut(&mut records, pubkey)?;
|
||||
record.updated_at = util::now_iso();
|
||||
record.last_error = Some(error);
|
||||
save_managed_agents(app, &records)
|
||||
}
|
||||
|
||||
@@ -575,7 +575,9 @@ pub fn sync_managed_agent_processes(
|
||||
record.last_error = if status.success() {
|
||||
None
|
||||
} else {
|
||||
Some(format!("harness exited with status {status}"))
|
||||
super::meaningful_agent_error_from_log(&runtime.log_path)
|
||||
.unwrap_or_else(|| format!("harness exited with status {status}"))
|
||||
.into()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1041,6 +1043,26 @@ fn child_rust_log_filter() -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the relay-mesh model id for agents whose provider env points at the
|
||||
/// local mesh client endpoint. The caller is responsible for ensuring that
|
||||
/// endpoint is alive before spawning the agent process.
|
||||
pub fn relay_mesh_model_id(record: &ManagedAgentRecord) -> Option<String> {
|
||||
let base_url = record.env_vars.get("OPENAI_COMPAT_BASE_URL")?.trim();
|
||||
if base_url.trim_end_matches('/') != "http://127.0.0.1:9337/v1" {
|
||||
return None;
|
||||
}
|
||||
let provider = record.env_vars.get("SPROUT_AGENT_PROVIDER")?.trim();
|
||||
if provider != "openai" {
|
||||
return None;
|
||||
}
|
||||
record
|
||||
.env_vars
|
||||
.get("OPENAI_COMPAT_MODEL")
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub fn start_managed_agent_process(
|
||||
app: &AppHandle,
|
||||
record: &mut ManagedAgentRecord,
|
||||
@@ -1147,6 +1169,7 @@ pub fn stop_managed_agent_process(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::relay_mesh_model_id;
|
||||
use crate::managed_agents::known_acp_provider;
|
||||
|
||||
#[test]
|
||||
@@ -1238,6 +1261,36 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_mesh_model_id_detects_mesh_preset_env() {
|
||||
let mut rec = fixture(RespondTo::OwnerOnly, vec![], Some("tag".into()));
|
||||
rec.env_vars = std::collections::BTreeMap::from([
|
||||
("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()),
|
||||
(
|
||||
"OPENAI_COMPAT_BASE_URL".to_string(),
|
||||
"http://127.0.0.1:9337/v1/".to_string(),
|
||||
),
|
||||
("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()),
|
||||
]);
|
||||
|
||||
assert_eq!(relay_mesh_model_id(&rec).as_deref(), Some("Qwen3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_mesh_model_id_ignores_non_mesh_openai_env() {
|
||||
let mut rec = fixture(RespondTo::OwnerOnly, vec![], Some("tag".into()));
|
||||
rec.env_vars = std::collections::BTreeMap::from([
|
||||
("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()),
|
||||
(
|
||||
"OPENAI_COMPAT_BASE_URL".to_string(),
|
||||
"https://api.openai.com/v1".to_string(),
|
||||
),
|
||||
("OPENAI_COMPAT_MODEL".to_string(), "gpt-5".to_string()),
|
||||
]);
|
||||
|
||||
assert_eq!(relay_mesh_model_id(&rec), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_env_owner_only_sets_mode_and_removes_others() {
|
||||
let rec = fixture(RespondTo::OwnerOnly, vec![], Some("tag".into()));
|
||||
|
||||
@@ -198,8 +198,55 @@ fn bytecount_newlines(buf: &[u8]) -> usize {
|
||||
buf.iter().filter(|&&b| b == b'\n').count()
|
||||
}
|
||||
|
||||
pub fn meaningful_agent_error_from_log(path: &Path) -> Option<String> {
|
||||
let tail = read_log_tail(path, 200).ok()?;
|
||||
tail.lines().rev().map(str::trim).find_map(|line| {
|
||||
if line.starts_with("Agent reported error:") {
|
||||
return Some(line.to_string());
|
||||
}
|
||||
if line.starts_with("llm auth:") {
|
||||
return Some(format!("Agent reported error: {line}"));
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::Write as _;
|
||||
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
fn write_log(content: &str) -> NamedTempFile {
|
||||
let mut file = NamedTempFile::new().expect("temp log");
|
||||
file.write_all(content.as_bytes()).expect("write log");
|
||||
file
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meaningful_agent_error_from_log_promotes_wrapped_llm_auth() {
|
||||
let file = write_log("noise\nAgent reported error: llm auth: denied\n");
|
||||
assert_eq!(
|
||||
super::meaningful_agent_error_from_log(file.path()).as_deref(),
|
||||
Some("Agent reported error: llm auth: denied")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meaningful_agent_error_from_log_promotes_unwrapped_llm_auth() {
|
||||
let file = write_log("noise\nllm auth: denied\n");
|
||||
assert_eq!(
|
||||
super::meaningful_agent_error_from_log(file.path()).as_deref(),
|
||||
Some("Agent reported error: llm auth: denied")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meaningful_agent_error_from_log_does_not_promote_midline_auth_text() {
|
||||
let file = write_log("noise before llm auth: denied\n");
|
||||
assert!(super::meaningful_agent_error_from_log(file.path()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_ansi_from_typical_tracing_line() {
|
||||
let input = "\x1b[2m2026-05-27T15:16:32\x1b[0m \x1b[32m INFO\x1b[0m \x1b[2msprout_acp\x1b[0m\x1b[2m:\x1b[0m starting";
|
||||
|
||||
498
desktop/src-tauri/src/mesh_llm/mod.rs
Normal file
498
desktop/src-tauri/src/mesh_llm/mod.rs
Normal file
@@ -0,0 +1,498 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use mesh_llm_sdk::{client, serve, EmbeddedNodeHandle, MeshDiscoveryMode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const DEFAULT_MESH_API_PORT: u16 = 9337;
|
||||
const DEFAULT_MESH_CONSOLE_PORT: u16 = 3131;
|
||||
const MESH_STATUS_KIND: u64 = 30_621;
|
||||
const RELAY_MESH_API_BASE_URL: &str = "http://127.0.0.1:9337/v1";
|
||||
const RELAY_MESH_API_KEY_PLACEHOLDER: &str = "sprout-mesh-local";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeshModelOption {
|
||||
pub id: String,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeshServeTarget {
|
||||
pub model_id: String,
|
||||
pub model_name: Option<String>,
|
||||
pub endpoint_addr: String,
|
||||
pub node_name: Option<String>,
|
||||
pub capacity: Option<MeshTargetCapacity>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeshTargetCapacity {
|
||||
pub vram_gb: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MeshHealthStatus {
|
||||
Ok,
|
||||
Degraded,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeshHealth {
|
||||
pub status: MeshHealthStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
impl MeshHealth {
|
||||
fn ok() -> Self {
|
||||
Self {
|
||||
status: MeshHealthStatus::Ok,
|
||||
reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn degraded(reason: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: MeshHealthStatus::Degraded,
|
||||
reason: Some(reason.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn failed(reason: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: MeshHealthStatus::Failed,
|
||||
reason: Some(reason.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeshAvailability {
|
||||
pub capable: bool,
|
||||
pub admitted: bool,
|
||||
pub available: bool,
|
||||
pub reason: Option<String>,
|
||||
pub models: Vec<MeshModelOption>,
|
||||
pub serve_targets: Vec<MeshServeTarget>,
|
||||
}
|
||||
|
||||
impl MeshAvailability {
|
||||
pub fn unavailable(reason: impl Into<String>) -> Self {
|
||||
Self {
|
||||
capable: false,
|
||||
admitted: false,
|
||||
available: false,
|
||||
reason: Some(reason.into()),
|
||||
models: Vec::new(),
|
||||
serve_targets: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MeshNodeMode {
|
||||
Serve,
|
||||
Client,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MeshNodeState {
|
||||
Off,
|
||||
Starting,
|
||||
Running,
|
||||
Stopping,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StartMeshNodeRequest {
|
||||
pub mode: MeshNodeMode,
|
||||
#[serde(default)]
|
||||
pub model_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub max_vram_gb: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub join_token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub iroh_relay_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub iroh_relay_auth: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EnsureMeshClientRequest {
|
||||
pub model_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeshNodeStatus {
|
||||
pub state: MeshNodeState,
|
||||
pub mode: Option<MeshNodeMode>,
|
||||
pub health: MeshHealth,
|
||||
pub api_base_url: Option<String>,
|
||||
pub console_url: Option<String>,
|
||||
pub model_id: Option<String>,
|
||||
pub model_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub invite_token: Option<String>,
|
||||
}
|
||||
|
||||
pub fn stopped_status() -> MeshNodeStatus {
|
||||
MeshNodeStatus {
|
||||
state: MeshNodeState::Off,
|
||||
mode: None,
|
||||
health: MeshHealth::ok(),
|
||||
api_base_url: None,
|
||||
console_url: None,
|
||||
model_id: None,
|
||||
model_name: None,
|
||||
invite_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DesktopMeshRuntime {
|
||||
handle: EmbeddedNodeHandle,
|
||||
mode: MeshNodeMode,
|
||||
model_id: Option<String>,
|
||||
model_name: Option<String>,
|
||||
}
|
||||
|
||||
impl DesktopMeshRuntime {
|
||||
pub async fn start(request: StartMeshNodeRequest) -> anyhow::Result<Self> {
|
||||
validate_no_leak_request(&request)?;
|
||||
let model_id = request
|
||||
.model_id
|
||||
.clone()
|
||||
.filter(|value| !value.trim().is_empty());
|
||||
let model_name = model_id.clone();
|
||||
let handle = match request.mode {
|
||||
MeshNodeMode::Serve => {
|
||||
let model = model_id
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("modelId is required for serve mode"))?;
|
||||
let mut builder = serve::EmbeddedServeConfig::builder()
|
||||
.model(model)
|
||||
.api_port(DEFAULT_MESH_API_PORT)
|
||||
.console_port(DEFAULT_MESH_CONSOLE_PORT)
|
||||
.publish(false)
|
||||
.auto_join(false)
|
||||
.discovery_mode(MeshDiscoveryMode::Nostr)
|
||||
.console_ui(true);
|
||||
if let Some(max_vram_gb) = request.max_vram_gb {
|
||||
builder = builder.max_vram_gb(max_vram_gb as f64);
|
||||
}
|
||||
if let Some(join_token) = request.join_token.as_deref() {
|
||||
builder = builder.join_token(join_token);
|
||||
}
|
||||
if let Some(relay) = request.iroh_relay_url.as_deref() {
|
||||
builder = builder.iroh_relay(relay);
|
||||
if let Some(auth) = request.iroh_relay_auth.as_deref() {
|
||||
builder = builder.iroh_relay_auth(relay, auth);
|
||||
}
|
||||
}
|
||||
serve::start(builder.build()).await?
|
||||
}
|
||||
MeshNodeMode::Client => {
|
||||
let mut builder = client::EmbeddedClientConfig::builder()
|
||||
.api_port(DEFAULT_MESH_API_PORT)
|
||||
.console_port(DEFAULT_MESH_CONSOLE_PORT)
|
||||
.publish(false)
|
||||
.auto_join(false)
|
||||
.discovery_mode(MeshDiscoveryMode::Nostr)
|
||||
.console_ui(true);
|
||||
if let Some(join_token) = request.join_token.as_deref() {
|
||||
builder = builder.join_token(join_token);
|
||||
}
|
||||
if let Some(relay) = request.iroh_relay_url.as_deref() {
|
||||
builder = builder.iroh_relay(relay);
|
||||
if let Some(auth) = request.iroh_relay_auth.as_deref() {
|
||||
builder = builder.iroh_relay_auth(relay, auth);
|
||||
}
|
||||
}
|
||||
client::start(builder.build()).await?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
handle,
|
||||
mode: request.mode,
|
||||
model_id,
|
||||
model_name,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn status(&self) -> anyhow::Result<MeshNodeStatus> {
|
||||
let status = self.handle.status().await?;
|
||||
self.status_from_sdk(status)
|
||||
}
|
||||
|
||||
pub async fn installed_models(&self) -> anyhow::Result<Vec<MeshModelOption>> {
|
||||
let status = self.handle.status().await?;
|
||||
Ok(models_from_status_payload(Some(&status.payload)))
|
||||
}
|
||||
|
||||
fn status_from_sdk(
|
||||
&self,
|
||||
status: mesh_llm_sdk::EmbeddedNodeStatus,
|
||||
) -> anyhow::Result<MeshNodeStatus> {
|
||||
let health = health_from_payload(&status.payload);
|
||||
Ok(MeshNodeStatus {
|
||||
state: if matches!(health.status, MeshHealthStatus::Failed) {
|
||||
MeshNodeState::Failed
|
||||
} else {
|
||||
MeshNodeState::Running
|
||||
},
|
||||
mode: Some(self.mode),
|
||||
health,
|
||||
api_base_url: Some(status.api_base_url),
|
||||
console_url: Some(status.console_url),
|
||||
model_id: self.model_id.clone(),
|
||||
model_name: self.model_name.clone(),
|
||||
invite_token: status.invite_token,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn stop(self) -> anyhow::Result<()> {
|
||||
self.handle.stop().await
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_no_leak_request(request: &StartMeshNodeRequest) -> anyhow::Result<()> {
|
||||
if request.join_token.as_deref().is_some_and(str::is_empty) {
|
||||
anyhow::bail!("joinToken cannot be empty when provided");
|
||||
}
|
||||
if request.iroh_relay_url.as_deref().is_none_or(str::is_empty) {
|
||||
anyhow::bail!("relay NIP-11 must advertise iroh_relay_url before starting mesh");
|
||||
}
|
||||
if request.iroh_relay_auth.as_deref().is_none_or(str::is_empty) {
|
||||
anyhow::bail!("iroh relay admission bearer is required before starting mesh");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn health_from_payload(payload: &serde_json::Value) -> MeshHealth {
|
||||
if let Some(reason) = find_progressish_reason(payload) {
|
||||
return MeshHealth::degraded(reason);
|
||||
}
|
||||
if let Some(status) = payload.get("status").and_then(serde_json::Value::as_str) {
|
||||
if matches!(status, "failed" | "error") {
|
||||
return MeshHealth::failed(status);
|
||||
}
|
||||
}
|
||||
MeshHealth::ok()
|
||||
}
|
||||
|
||||
fn find_progressish_reason(value: &serde_json::Value) -> Option<String> {
|
||||
// Match a typed phase field (not stringify-and-grep over the whole payload).
|
||||
let phase = ["phase", "status", "state", "stage"]
|
||||
.into_iter()
|
||||
.find_map(|key| value.get(key).and_then(serde_json::Value::as_str))?
|
||||
.to_ascii_lowercase();
|
||||
for needle in ["download", "fetch", "resolv", "prepar"] {
|
||||
if phase.contains(needle) {
|
||||
return Some(match needle {
|
||||
"download" => "downloading model".to_string(),
|
||||
"fetch" => "fetching model".to_string(),
|
||||
"resolv" => "resolving model".to_string(),
|
||||
_ => "preparing model".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn models_from_status_payload(payload: Option<&serde_json::Value>) -> Vec<MeshModelOption> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(payload) = payload {
|
||||
collect_model_options(payload, &mut out);
|
||||
}
|
||||
dedupe_models(out)
|
||||
}
|
||||
|
||||
fn collect_model_options(value: &serde_json::Value, out: &mut Vec<MeshModelOption>) {
|
||||
match value {
|
||||
serde_json::Value::Object(map) => {
|
||||
if let Some(id) = map
|
||||
.get("model_id")
|
||||
.or_else(|| map.get("modelId"))
|
||||
.or_else(|| map.get("model_ref"))
|
||||
.or_else(|| map.get("modelRef"))
|
||||
.or_else(|| map.get("id"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
{
|
||||
let name = map
|
||||
.get("name")
|
||||
.or_else(|| map.get("display_name"))
|
||||
.or_else(|| map.get("displayName"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
push_model(out, id, name);
|
||||
}
|
||||
for child in map.values() {
|
||||
collect_model_options(child, out);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(values) => {
|
||||
for child in values {
|
||||
collect_model_options(child, out);
|
||||
}
|
||||
}
|
||||
serde_json::Value::String(value) if looks_like_model_ref(value) => {
|
||||
push_model(out, value, None);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_model_ref(value: &str) -> bool {
|
||||
// Family-agnostic: a bare string is a ref only via URI scheme or .gguf ext.
|
||||
let trimmed = value.trim();
|
||||
!trimmed.is_empty()
|
||||
&& (trimmed.starts_with("hf://") || trimmed.to_ascii_lowercase().ends_with(".gguf"))
|
||||
}
|
||||
|
||||
fn push_model(out: &mut Vec<MeshModelOption>, id: &str, name: Option<String>) {
|
||||
let id = id.trim();
|
||||
if id.is_empty() || id.starts_with("http://") || id.starts_with("https://") {
|
||||
return;
|
||||
}
|
||||
out.push(MeshModelOption {
|
||||
id: id.to_string(),
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
fn dedupe_models(models: Vec<MeshModelOption>) -> Vec<MeshModelOption> {
|
||||
let mut by_id = BTreeMap::<String, Option<String>>::new();
|
||||
for model in models {
|
||||
by_id
|
||||
.entry(model.id)
|
||||
.and_modify(|name| {
|
||||
if name.is_none() {
|
||||
*name = model.name.clone();
|
||||
}
|
||||
})
|
||||
.or_insert(model.name);
|
||||
}
|
||||
by_id
|
||||
.into_iter()
|
||||
.map(|(id, name)| MeshModelOption { id, name })
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeshAgentPresetRequest {
|
||||
pub model_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeshAgentPreset {
|
||||
pub provider_id: String,
|
||||
pub label: String,
|
||||
pub acp_command: String,
|
||||
pub agent_command: String,
|
||||
pub agent_args: Vec<String>,
|
||||
pub mcp_command: String,
|
||||
pub model: String,
|
||||
pub env_vars: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
pub fn agent_preset(request: MeshAgentPresetRequest) -> Result<MeshAgentPreset, String> {
|
||||
let model = request.model_id.trim();
|
||||
if model.is_empty() {
|
||||
return Err("modelId is required".to_string());
|
||||
}
|
||||
Ok(MeshAgentPreset {
|
||||
provider_id: "relay-mesh".to_string(),
|
||||
label: "Relay mesh".to_string(),
|
||||
acp_command: crate::managed_agents::DEFAULT_ACP_COMMAND.to_string(),
|
||||
agent_command: crate::managed_agents::DEFAULT_AGENT_COMMAND.to_string(),
|
||||
agent_args: Vec::new(),
|
||||
mcp_command: crate::managed_agents::DEFAULT_MCP_COMMAND.to_string(),
|
||||
model: model.to_string(),
|
||||
env_vars: BTreeMap::from([
|
||||
("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()),
|
||||
(
|
||||
"OPENAI_COMPAT_BASE_URL".to_string(),
|
||||
RELAY_MESH_API_BASE_URL.to_string(),
|
||||
),
|
||||
("OPENAI_COMPAT_MODEL".to_string(), model.to_string()),
|
||||
(
|
||||
"OPENAI_COMPAT_API_KEY".to_string(),
|
||||
RELAY_MESH_API_KEY_PLACEHOLDER.to_string(),
|
||||
),
|
||||
("OPENAI_COMPAT_API".to_string(), "chat".to_string()),
|
||||
]),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn availability_from_events(events: Vec<nostr::Event>) -> MeshAvailability {
|
||||
let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else {
|
||||
return MeshAvailability::unavailable("relay mesh status is not published yet");
|
||||
};
|
||||
|
||||
let Ok(content) = serde_json::from_str::<serde_json::Value>(&event.content) else {
|
||||
return MeshAvailability::unavailable("relay mesh status is malformed");
|
||||
};
|
||||
|
||||
let serve_targets = content
|
||||
.get("serveTargets")
|
||||
.or_else(|| content.get("serve_targets"))
|
||||
.cloned()
|
||||
.and_then(|value| serde_json::from_value::<Vec<MeshServeTarget>>(value).ok())
|
||||
.unwrap_or_default();
|
||||
let models = content
|
||||
.get("models")
|
||||
.cloned()
|
||||
.and_then(|value| serde_json::from_value::<Vec<MeshModelOption>>(value).ok())
|
||||
.unwrap_or_else(|| {
|
||||
dedupe_models(
|
||||
serve_targets
|
||||
.iter()
|
||||
.map(|target| MeshModelOption {
|
||||
id: target.model_id.clone(),
|
||||
name: target.model_name.clone(),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
});
|
||||
|
||||
let available = !serve_targets.is_empty();
|
||||
MeshAvailability {
|
||||
capable: true,
|
||||
admitted: true,
|
||||
available,
|
||||
reason: if available {
|
||||
None
|
||||
} else {
|
||||
Some("no relay mesh serve targets are available".to_string())
|
||||
},
|
||||
models,
|
||||
serve_targets,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mesh_status_filter() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"kinds": [MESH_STATUS_KIND],
|
||||
"#d": ["sprout-relay-mesh"],
|
||||
"limit": 1
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod mod_tests;
|
||||
34
desktop/src-tauri/src/mesh_llm/mod_tests.rs
Normal file
34
desktop/src-tauri/src/mesh_llm/mod_tests.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
//! Unit tests for `mesh_llm/mod.rs` private helpers (kept in a sibling file so
|
||||
//! `mod.rs` stays under the 500-line budget; `#[path]`-included from there).
|
||||
use super::{find_progressish_reason, looks_like_model_ref};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn progressish_reads_typed_phase_not_whole_tree() {
|
||||
assert_eq!(
|
||||
find_progressish_reason(&json!({"phase": "downloading weights"})),
|
||||
Some("downloading model".to_string())
|
||||
);
|
||||
// Regression (Sami N1): an unrelated field mentioning a progress word must
|
||||
// not trip the badge — only the typed phase field counts.
|
||||
assert_eq!(
|
||||
find_progressish_reason(&json!({
|
||||
"phase": "ready",
|
||||
"model_name": "prepared-qwen-preparing"
|
||||
})),
|
||||
None
|
||||
);
|
||||
assert_eq!(find_progressish_reason(&json!({"foo": "bar"})), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_ref_is_family_agnostic() {
|
||||
assert!(looks_like_model_ref("hf://org/model"));
|
||||
assert!(looks_like_model_ref("some-model.gguf"));
|
||||
assert!(looks_like_model_ref("Some-Model.GGUF"));
|
||||
// Families that used to be hardcoded must route via the structured path,
|
||||
// not a name allowlist here (Sami N2):
|
||||
assert!(!looks_like_model_ref("Mistral-7B"));
|
||||
assert!(!looks_like_model_ref("Qwen3-35B"));
|
||||
assert!(!looks_like_model_ref(""));
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
friendlyAgentLastError,
|
||||
RELAY_MESH_DENIED_COPY,
|
||||
} from "./friendlyAgentLastError.ts";
|
||||
|
||||
test("null lastError → null", () => {
|
||||
assert.equal(friendlyAgentLastError(null), null);
|
||||
});
|
||||
|
||||
test("empty/whitespace lastError → null", () => {
|
||||
assert.equal(friendlyAgentLastError(""), null);
|
||||
assert.equal(friendlyAgentLastError(" "), null);
|
||||
});
|
||||
|
||||
test("sprout-acp wrapped auth failure → denied copy", () => {
|
||||
const result = friendlyAgentLastError(
|
||||
"Agent reported error: llm auth: 401 unauthorized: ...",
|
||||
);
|
||||
assert.deepEqual(result, {
|
||||
severity: "denied",
|
||||
copy: RELAY_MESH_DENIED_COPY,
|
||||
});
|
||||
});
|
||||
|
||||
test("unwrapped sprout-agent prefix → denied copy", () => {
|
||||
// sprout-agent's AgentError::LlmAuth Display is "llm auth: <body>"; if the
|
||||
// desktop ever picks that up directly (no AcpError wrapper), we should
|
||||
// still recognize it as denial.
|
||||
const result = friendlyAgentLastError("llm auth: 403 forbidden");
|
||||
assert.deepEqual(result, {
|
||||
severity: "denied",
|
||||
copy: RELAY_MESH_DENIED_COPY,
|
||||
});
|
||||
});
|
||||
|
||||
test("generic harness exit message → passthrough", () => {
|
||||
const result = friendlyAgentLastError("harness exited with status code 137");
|
||||
assert.deepEqual(result, {
|
||||
severity: "generic",
|
||||
copy: "harness exited with status code 137",
|
||||
});
|
||||
});
|
||||
|
||||
test("trims whitespace before matching", () => {
|
||||
const result = friendlyAgentLastError(
|
||||
" Agent reported error: llm auth: nope\n",
|
||||
);
|
||||
assert.equal(result?.severity, "denied");
|
||||
assert.equal(result?.copy, RELAY_MESH_DENIED_COPY);
|
||||
});
|
||||
|
||||
test("substring 'llm auth:' that isn't at start is NOT treated as denial", () => {
|
||||
// Some other failure that happens to mention 'llm auth:' deep in a message
|
||||
// — we only promote when the failure *is* an auth failure, signalled by
|
||||
// the prefix. Anything else stays passthrough so we don't lie about the
|
||||
// cause of an unrelated crash.
|
||||
const result = friendlyAgentLastError(
|
||||
"harness exited with status code 1: stderr mentions llm auth: misleadingly",
|
||||
);
|
||||
assert.equal(result?.severity, "generic");
|
||||
assert.ok(result?.copy.startsWith("harness exited"));
|
||||
});
|
||||
|
||||
test("non-auth Agent reported error stays generic", () => {
|
||||
const result = friendlyAgentLastError(
|
||||
"Agent reported error: llm: 500 internal server error",
|
||||
);
|
||||
assert.equal(result?.severity, "generic");
|
||||
assert.equal(
|
||||
result?.copy,
|
||||
"Agent reported error: llm: 500 internal server error",
|
||||
);
|
||||
});
|
||||
57
desktop/src/features/agents/lib/friendlyAgentLastError.ts
Normal file
57
desktop/src/features/agents/lib/friendlyAgentLastError.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Promote certain machine-readable `lastError` strings to user-facing copy.
|
||||
*
|
||||
* The mesh-llm seam (Max's commit `5196203…`) flows like this:
|
||||
* sprout-agent — gets HTTP 401/403 from the OpenAI-compatible mesh endpoint
|
||||
* → raises `AgentError::LlmAuth("…")` (json_rpc_code `-32001`,
|
||||
* Display prefix `"llm auth: …"`)
|
||||
* sprout-acp — wraps it as `AcpError::AgentError("Agent reported error: llm auth: …")`
|
||||
* desktop managed-agent supervisor — on nonzero exit, scans `read_log_tail`
|
||||
* for `"Agent reported error:"` / `"llm auth:"` and persists
|
||||
* that line into `ManagedAgent.lastError` instead of the
|
||||
* generic `"harness exited with status …"`.
|
||||
*
|
||||
* v1 caveat (named, not hidden): the typed `-32001` code never reaches
|
||||
* desktop structurally — desktop only supervises the child process and ACP's
|
||||
* `ObserverHandle` is in-process inside that child. So `lastError` is the
|
||||
* recovered string, not the original code. The follow-up to make this 9/10
|
||||
* structural is an ACP status file or desktop-owned observer sink; that lives
|
||||
* elsewhere. For now we match the string this function exists to render.
|
||||
*
|
||||
* Returns:
|
||||
* - null when there's nothing to show (null/empty lastError).
|
||||
* - A `{ severity: "denied"; copy: string }` object for the auth-failure
|
||||
* case, so the UI can render with the right visual weight (destructive).
|
||||
* - A `{ severity: "generic"; copy: string }` pass-through for any other
|
||||
* lastError, so generic harness exits still surface their text instead of
|
||||
* being swallowed.
|
||||
*/
|
||||
export type FriendlyAgentLastError =
|
||||
| { severity: "denied"; copy: string }
|
||||
| { severity: "generic"; copy: string };
|
||||
|
||||
/**
|
||||
* The exact copy for the relay-mesh denial. Centralized as a constant so the
|
||||
* test asserts the user-facing string verbatim rather than a fuzzy pattern.
|
||||
*/
|
||||
export const RELAY_MESH_DENIED_COPY =
|
||||
"Relay mesh denied this agent — check your relay membership.";
|
||||
|
||||
export function friendlyAgentLastError(
|
||||
raw: string | null,
|
||||
): FriendlyAgentLastError | null {
|
||||
if (raw == null) return null;
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
|
||||
// Match either the unwrapped sprout-agent prefix or the sprout-acp wrap.
|
||||
// The desktop supervisor recovers whichever appears first in the log tail.
|
||||
if (
|
||||
trimmed.startsWith("Agent reported error: llm auth:") ||
|
||||
trimmed.startsWith("llm auth:")
|
||||
) {
|
||||
return { severity: "denied", copy: RELAY_MESH_DENIED_COPY };
|
||||
}
|
||||
|
||||
return { severity: "generic", copy: trimmed };
|
||||
}
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
ProviderConfigFields,
|
||||
} from "./ProviderConfigFields";
|
||||
import { CreateAgentRespondToField } from "./RespondToField";
|
||||
import { RelayMeshAgentSection } from "@/features/mesh-compute/ui/RelayMeshAgentSection";
|
||||
import { meshEnsureClientNode } from "@/shared/api/tauriMesh";
|
||||
import { useLastRuntimeProvider } from "@/features/agents/lib/useLastRuntimeProvider";
|
||||
|
||||
// ── Dialog ────────────────────────────────────────────────────────────────────
|
||||
@@ -86,6 +88,17 @@ export function CreateAgentDialog({
|
||||
React.useState<BackendProviderProbeResult | null>(null);
|
||||
const [probeError, setProbeError] = React.useState<string | null>(null);
|
||||
|
||||
// ── Relay-mesh flow state ──────────────────────────────────────────────────
|
||||
// When `useMesh` is on, the agent runs sprout-agent against a member's
|
||||
// shared compute. The ACP runtime + backend selectors are hidden; runtime
|
||||
// fields are driven by `mesh_agent_preset(meshModelId)` and the submit
|
||||
// input carries `model: meshModelId`.
|
||||
const [useMesh, setUseMesh] = React.useState(false);
|
||||
const [meshModelId, setMeshModelId] = React.useState("");
|
||||
const [meshClientError, setMeshClientError] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const providers = providersQuery.data ?? [];
|
||||
const allProviders = allProvidersQuery.data ?? [];
|
||||
const unavailableCount = allProviders.filter(
|
||||
@@ -102,7 +115,10 @@ export function CreateAgentDialog({
|
||||
() => backendProviders.find((p) => p.id === runOn) ?? null,
|
||||
[backendProviders, runOn],
|
||||
);
|
||||
const isProviderMode = runOn !== "local";
|
||||
// Relay mesh always runs in local mode (sprout-agent + OpenAI-compat env);
|
||||
// when on, it suppresses the backend "Run on" branch even if a stale
|
||||
// `runOn` value remains. The relay-mesh path is its own thing.
|
||||
const isProviderMode = !useMesh && runOn !== "local";
|
||||
|
||||
const isSpawnSupported =
|
||||
prereqs?.acp.available === true && prereqs?.mcp.available === true;
|
||||
@@ -228,6 +244,9 @@ export function CreateAgentDialog({
|
||||
setProviderConfig({});
|
||||
setProbedProvider(null);
|
||||
setProbeError(null);
|
||||
setUseMesh(false);
|
||||
setMeshModelId("");
|
||||
setMeshClientError(null);
|
||||
setRespondTo("owner-only");
|
||||
setRespondToAllowlist([]);
|
||||
createMutation.reset();
|
||||
@@ -298,11 +317,24 @@ export function CreateAgentDialog({
|
||||
// fields and config schema are only known after a successful probe.
|
||||
!(isProviderMode && !probedProvider) &&
|
||||
providerConfigComplete &&
|
||||
// Relay-mesh mode requires the user to have picked a model from the
|
||||
// availability dropdown — without a model the preset is empty.
|
||||
!(useMesh && meshModelId.trim().length === 0) &&
|
||||
respondToValid &&
|
||||
!createMutation.isPending;
|
||||
|
||||
async function handleSubmit() {
|
||||
setMeshClientError(null);
|
||||
try {
|
||||
if (useMesh) {
|
||||
try {
|
||||
await meshEnsureClientNode(meshModelId.trim());
|
||||
} catch (err) {
|
||||
setMeshClientError(err instanceof Error ? err.message : String(err));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Only send the allowlist when the mode is actually "allowlist".
|
||||
// Other modes ignore it server-side, but keeping the wire clean makes
|
||||
// the agent record easier to inspect.
|
||||
@@ -359,6 +391,7 @@ export function CreateAgentDialog({
|
||||
: undefined,
|
||||
systemPrompt: systemPrompt.trim() || undefined,
|
||||
envVars,
|
||||
model: useMesh ? meshModelId.trim() || undefined : undefined,
|
||||
spawnAfterCreate,
|
||||
startOnAppLaunch,
|
||||
backend: { type: "local" },
|
||||
@@ -388,8 +421,46 @@ export function CreateAgentDialog({
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-6 py-5">
|
||||
<CreateAgentBasicsFields name={name} onNameChange={setName} />
|
||||
|
||||
<RelayMeshAgentSection
|
||||
current={{
|
||||
acpCommand,
|
||||
agentCommand,
|
||||
agentArgs: agentArgs
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0),
|
||||
mcpCommand,
|
||||
model: meshModelId || null,
|
||||
envVars,
|
||||
}}
|
||||
modelId={meshModelId}
|
||||
onModelIdChange={(nextId, patch) => {
|
||||
setMeshModelId(nextId);
|
||||
if (patch == null) return;
|
||||
// Fan out the preset into the existing setters so the rest
|
||||
// of the dialog (and the submit branch) see normal local-mode
|
||||
// values — relay-mesh is a curated local agent.
|
||||
setAcpCommand(patch.acpCommand);
|
||||
setAgentCommand(patch.agentCommand);
|
||||
setAgentArgs(patch.agentArgs.join(","));
|
||||
setMcpCommand(patch.mcpCommand);
|
||||
setEnvVars(patch.envVars);
|
||||
}}
|
||||
onUseMeshChange={(next) => {
|
||||
setUseMesh(next);
|
||||
if (!next) {
|
||||
// Clearing the toggle: drop the model selection so the
|
||||
// submit guard doesn't fire on a stale value. The runtime
|
||||
// fields keep whatever the user had — they can re-pick
|
||||
// ACP runtime or stay with the preset values, their call.
|
||||
setMeshModelId("");
|
||||
}
|
||||
}}
|
||||
useMesh={useMesh}
|
||||
/>
|
||||
|
||||
{/* Run on selector — only shown when backend providers are discovered */}
|
||||
{backendProviders.length > 0 ? (
|
||||
{!useMesh && backendProviders.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="agent-run-on">
|
||||
Run on
|
||||
@@ -442,7 +513,7 @@ export function CreateAgentDialog({
|
||||
) : null}
|
||||
|
||||
{/* Local mode: show the ACP runtime selector */}
|
||||
{!isProviderMode ? (
|
||||
{!isProviderMode && !useMesh ? (
|
||||
<CreateAgentRuntimeProviderField
|
||||
onProviderChange={handleProviderChange}
|
||||
providers={providers}
|
||||
@@ -553,6 +624,12 @@ export function CreateAgentDialog({
|
||||
value={envVars}
|
||||
/>
|
||||
|
||||
{meshClientError ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
Mesh client failed to start: {meshClientError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{createMutation.error instanceof Error ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{createMutation.error.message}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { EditAgentDialog } from "./EditAgentDialog";
|
||||
import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError";
|
||||
import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel";
|
||||
import { ModelPicker } from "./ModelPicker";
|
||||
import { truncatePubkey } from "./agentUi";
|
||||
@@ -86,6 +87,13 @@ export function ManagedAgentRow({
|
||||
: isLocal
|
||||
? "Ready to launch"
|
||||
: "Managed remotely";
|
||||
// When the harness recovered a meaningful error string from the agent's
|
||||
// log tail (Max's seam in `managed_agents/storage.rs`), promote it to
|
||||
// user-visible copy below the process detail. Specifically renders the
|
||||
// friendly "Relay mesh denied this agent — check your relay membership."
|
||||
// for auth failures so the user knows it's a membership thing, not a
|
||||
// crash. Generic exits stay verbatim so we don't lie about other failures.
|
||||
const friendlyError = friendlyAgentLastError(agent.lastError);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -115,6 +123,7 @@ export function ManagedAgentRow({
|
||||
presenceStatus={presenceStatus}
|
||||
/>
|
||||
<StatusBlock
|
||||
friendlyError={friendlyError}
|
||||
presenceLoaded={presenceLoaded}
|
||||
presenceStatus={presenceStatus}
|
||||
processDetail={processDetail}
|
||||
@@ -135,6 +144,7 @@ export function ManagedAgentRow({
|
||||
presenceStatus={presenceStatus}
|
||||
/>
|
||||
<StatusBlock
|
||||
friendlyError={friendlyError}
|
||||
presenceLoaded={presenceLoaded}
|
||||
presenceStatus={presenceStatus}
|
||||
processDetail={processDetail}
|
||||
@@ -249,11 +259,13 @@ function AgentSummary({
|
||||
}
|
||||
|
||||
function StatusBlock({
|
||||
friendlyError,
|
||||
presenceLoaded,
|
||||
presenceStatus,
|
||||
processDetail,
|
||||
status,
|
||||
}: {
|
||||
friendlyError: ReturnType<typeof friendlyAgentLastError>;
|
||||
presenceLoaded: boolean;
|
||||
presenceStatus: PresenceStatus | undefined;
|
||||
processDetail: string;
|
||||
@@ -270,6 +282,19 @@ function StatusBlock({
|
||||
status={status}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{processDetail}</p>
|
||||
{friendlyError ? (
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs",
|
||||
friendlyError.severity === "denied"
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
data-testid="managed-agent-last-error"
|
||||
>
|
||||
{friendlyError.copy}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
177
desktop/src/features/mesh-compute/applyMeshAgentPreset.test.mjs
Normal file
177
desktop/src/features/mesh-compute/applyMeshAgentPreset.test.mjs
Normal file
@@ -0,0 +1,177 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
detectMeshPresetOverrides,
|
||||
meshAgentPresetPatch,
|
||||
} from "./applyMeshAgentPreset.ts";
|
||||
|
||||
const PRESET = {
|
||||
providerId: "relay-mesh",
|
||||
label: "Relay mesh",
|
||||
acpCommand: "sprout-acp",
|
||||
agentCommand: "sprout-agent",
|
||||
agentArgs: [],
|
||||
mcpCommand: "sprout-dev-mcp",
|
||||
model: "Qwen3-8B-Q4_K_M",
|
||||
envVars: {
|
||||
SPROUT_AGENT_PROVIDER: "openai",
|
||||
OPENAI_COMPAT_BASE_URL: "http://127.0.0.1:9337/v1",
|
||||
OPENAI_COMPAT_MODEL: "Qwen3-8B-Q4_K_M",
|
||||
OPENAI_COMPAT_API_KEY: "sprout-mesh-local",
|
||||
OPENAI_COMPAT_API: "chat",
|
||||
},
|
||||
};
|
||||
|
||||
// ── meshAgentPresetPatch ──────────────────────────────────────────────
|
||||
|
||||
test("patch carries the fields a managed-agent draft needs", () => {
|
||||
const patch = meshAgentPresetPatch(PRESET);
|
||||
assert.equal(patch.acpCommand, "sprout-acp");
|
||||
assert.equal(patch.agentCommand, "sprout-agent");
|
||||
assert.deepEqual(patch.agentArgs, []);
|
||||
assert.equal(patch.mcpCommand, "sprout-dev-mcp");
|
||||
assert.equal(patch.model, "Qwen3-8B-Q4_K_M");
|
||||
assert.equal(patch.envVars.OPENAI_COMPAT_MODEL, "Qwen3-8B-Q4_K_M");
|
||||
});
|
||||
|
||||
test("patch returns owned copies — caller cannot mutate the preset", () => {
|
||||
const patch = meshAgentPresetPatch(PRESET);
|
||||
patch.agentArgs.push("dirty");
|
||||
patch.envVars.DIRTY = "1";
|
||||
assert.deepEqual(PRESET.agentArgs, []);
|
||||
assert.equal(PRESET.envVars.DIRTY, undefined);
|
||||
});
|
||||
|
||||
// ── detectMeshPresetOverrides ─────────────────────────────────────────
|
||||
|
||||
test("empty draft has no overrides", () => {
|
||||
const overrides = detectMeshPresetOverrides(
|
||||
{
|
||||
acpCommand: "",
|
||||
agentCommand: "",
|
||||
agentArgs: [],
|
||||
mcpCommand: "",
|
||||
model: null,
|
||||
envVars: {},
|
||||
},
|
||||
PRESET,
|
||||
);
|
||||
assert.deepEqual(overrides, []);
|
||||
});
|
||||
|
||||
test("matching draft has no overrides", () => {
|
||||
const overrides = detectMeshPresetOverrides(
|
||||
{
|
||||
acpCommand: "sprout-acp",
|
||||
agentCommand: "sprout-agent",
|
||||
agentArgs: [],
|
||||
mcpCommand: "sprout-dev-mcp",
|
||||
model: "Qwen3-8B-Q4_K_M",
|
||||
envVars: {
|
||||
SPROUT_AGENT_PROVIDER: "openai",
|
||||
OPENAI_COMPAT_BASE_URL: "http://127.0.0.1:9337/v1",
|
||||
},
|
||||
},
|
||||
PRESET,
|
||||
);
|
||||
assert.deepEqual(overrides, []);
|
||||
});
|
||||
|
||||
test("differing model is reported as override", () => {
|
||||
const overrides = detectMeshPresetOverrides(
|
||||
{
|
||||
acpCommand: "sprout-acp",
|
||||
agentCommand: "sprout-agent",
|
||||
agentArgs: [],
|
||||
mcpCommand: "sprout-dev-mcp",
|
||||
model: "llama-3.2-3b-instruct",
|
||||
envVars: {},
|
||||
},
|
||||
PRESET,
|
||||
);
|
||||
assert.deepEqual(overrides, ["model"]);
|
||||
});
|
||||
|
||||
test("non-sprout-agent runtime + non-mesh model both reported", () => {
|
||||
const overrides = detectMeshPresetOverrides(
|
||||
{
|
||||
acpCommand: "sprout-acp",
|
||||
agentCommand: "goose",
|
||||
agentArgs: ["acp"],
|
||||
mcpCommand: "sprout-dev-mcp",
|
||||
model: "gpt-4o",
|
||||
envVars: {},
|
||||
},
|
||||
PRESET,
|
||||
);
|
||||
assert.deepEqual(overrides, ["agent runtime", "model"]);
|
||||
});
|
||||
|
||||
test("overlapping env-var with differing value is reported", () => {
|
||||
const overrides = detectMeshPresetOverrides(
|
||||
{
|
||||
acpCommand: "sprout-acp",
|
||||
agentCommand: "sprout-agent",
|
||||
agentArgs: [],
|
||||
mcpCommand: "sprout-dev-mcp",
|
||||
model: "Qwen3-8B-Q4_K_M",
|
||||
envVars: {
|
||||
SPROUT_AGENT_PROVIDER: "anthropic",
|
||||
},
|
||||
},
|
||||
PRESET,
|
||||
);
|
||||
assert.deepEqual(overrides, ["environment variables"]);
|
||||
});
|
||||
|
||||
test("overlapping env-var with same value is NOT reported", () => {
|
||||
const overrides = detectMeshPresetOverrides(
|
||||
{
|
||||
acpCommand: "sprout-acp",
|
||||
agentCommand: "sprout-agent",
|
||||
agentArgs: [],
|
||||
mcpCommand: "sprout-dev-mcp",
|
||||
model: "Qwen3-8B-Q4_K_M",
|
||||
envVars: {
|
||||
SPROUT_AGENT_PROVIDER: "openai",
|
||||
},
|
||||
},
|
||||
PRESET,
|
||||
);
|
||||
assert.deepEqual(overrides, []);
|
||||
});
|
||||
|
||||
test("additive env-var (new key) is not an override", () => {
|
||||
const overrides = detectMeshPresetOverrides(
|
||||
{
|
||||
acpCommand: "sprout-acp",
|
||||
agentCommand: "sprout-agent",
|
||||
agentArgs: [],
|
||||
mcpCommand: "sprout-dev-mcp",
|
||||
model: "Qwen3-8B-Q4_K_M",
|
||||
envVars: {
|
||||
SOME_USER_VAR: "kept",
|
||||
},
|
||||
},
|
||||
PRESET,
|
||||
);
|
||||
assert.deepEqual(overrides, []);
|
||||
});
|
||||
|
||||
test("empty model string treated like null (no override)", () => {
|
||||
// ManagedAgent.model is `string | null` but a fresh draft sometimes carries
|
||||
// "" instead of null. Either should be treated as "user hasn't picked yet."
|
||||
const overrides = detectMeshPresetOverrides(
|
||||
{
|
||||
acpCommand: "sprout-acp",
|
||||
agentCommand: "sprout-agent",
|
||||
agentArgs: [],
|
||||
mcpCommand: "sprout-dev-mcp",
|
||||
model: "",
|
||||
envVars: {},
|
||||
},
|
||||
PRESET,
|
||||
);
|
||||
assert.deepEqual(overrides, []);
|
||||
});
|
||||
95
desktop/src/features/mesh-compute/applyMeshAgentPreset.ts
Normal file
95
desktop/src/features/mesh-compute/applyMeshAgentPreset.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { MeshAgentPreset } from "@/shared/api/tauriMesh";
|
||||
|
||||
/**
|
||||
* Fields a Create-Agent draft needs to overwrite when the "Run on relay mesh"
|
||||
* flow is chosen. Mirror of `MeshAgentPreset` minus the picker metadata
|
||||
* (`providerId`, `label`) which the *flow* owns, not the agent record.
|
||||
*/
|
||||
export type MeshAgentPresetPatch = {
|
||||
acpCommand: string;
|
||||
agentCommand: string;
|
||||
agentArgs: string[];
|
||||
mcpCommand: string;
|
||||
model: string;
|
||||
envVars: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Turn a `mesh_agent_preset()` response into the fields a managed-agent draft
|
||||
* should carry. Idempotent and contract-stable: the same preset always yields
|
||||
* the same patch. The flow integrator calls this and `Object.assign`s the
|
||||
* result over the draft fields.
|
||||
*
|
||||
* Why a separate function: the dialog's state lives across many `useState`s
|
||||
* (acpCommand, agentCommand, agentArgs, mcpCommand, model, envVars), so the
|
||||
* caller fans out the patch. Doing the fan-out via a single helper keeps the
|
||||
* integration commit a one-liner per setter and the override behavior
|
||||
* testable without rendering.
|
||||
*/
|
||||
export function meshAgentPresetPatch(
|
||||
preset: MeshAgentPreset,
|
||||
): MeshAgentPresetPatch {
|
||||
return {
|
||||
acpCommand: preset.acpCommand,
|
||||
agentCommand: preset.agentCommand,
|
||||
agentArgs: [...preset.agentArgs],
|
||||
mcpCommand: preset.mcpCommand,
|
||||
model: preset.model,
|
||||
envVars: { ...preset.envVars },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether applying a Relay-mesh preset would overwrite values a user
|
||||
* (or persona) has already set. Drives the "Using relay mesh — overrides this
|
||||
* persona's model" honest-over-silent copy that Eva named as a requirement.
|
||||
*
|
||||
* Returns the list of human-readable field labels that the preset *changes*
|
||||
* from the current draft state, so the UI can render
|
||||
* "Using Relay mesh overrides: model, agent runtime"
|
||||
* rather than a silent overwrite.
|
||||
*
|
||||
* Empty list = no overrides; the preset is purely additive.
|
||||
*/
|
||||
export function detectMeshPresetOverrides(
|
||||
current: {
|
||||
acpCommand: string;
|
||||
agentCommand: string;
|
||||
agentArgs: string[];
|
||||
mcpCommand: string;
|
||||
model: string | null;
|
||||
envVars: Record<string, string>;
|
||||
},
|
||||
preset: MeshAgentPreset,
|
||||
): string[] {
|
||||
const changes: string[] = [];
|
||||
if (
|
||||
current.agentCommand !== "" &&
|
||||
current.agentCommand !== preset.agentCommand
|
||||
) {
|
||||
changes.push("agent runtime");
|
||||
}
|
||||
if (current.acpCommand !== "" && current.acpCommand !== preset.acpCommand) {
|
||||
changes.push("ACP harness");
|
||||
}
|
||||
if (current.mcpCommand !== "" && current.mcpCommand !== preset.mcpCommand) {
|
||||
changes.push("MCP server");
|
||||
}
|
||||
if (
|
||||
current.model != null &&
|
||||
current.model.length > 0 &&
|
||||
current.model !== preset.model
|
||||
) {
|
||||
changes.push("model");
|
||||
}
|
||||
// Env-var overlap: only mention if a *value* would change (a new key being
|
||||
// added is additive, not an override).
|
||||
const overlappingEnvKeys = Object.keys(preset.envVars).filter(
|
||||
(key) =>
|
||||
key in current.envVars && current.envVars[key] !== preset.envVars[key],
|
||||
);
|
||||
if (overlappingEnvKeys.length > 0) {
|
||||
changes.push("environment variables");
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
79
desktop/src/features/mesh-compute/classifyModelRef.test.mjs
Normal file
79
desktop/src/features/mesh-compute/classifyModelRef.test.mjs
Normal file
@@ -0,0 +1,79 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { classifyModelRef, modelRefHintLabel } from "./classifyModelRef.ts";
|
||||
|
||||
// ── classifyModelRef ──────────────────────────────────────────────────
|
||||
|
||||
test("empty string → unknown", () => {
|
||||
assert.deepEqual(classifyModelRef(""), { kind: "unknown" });
|
||||
assert.deepEqual(classifyModelRef(" "), { kind: "unknown" });
|
||||
});
|
||||
|
||||
test("hf:// prefix → huggingface", () => {
|
||||
assert.deepEqual(classifyModelRef("hf://meshllm/qwen3-8b@main"), {
|
||||
kind: "huggingface",
|
||||
ref: "hf://meshllm/qwen3-8b@main",
|
||||
});
|
||||
});
|
||||
|
||||
test("absolute path → local-path", () => {
|
||||
assert.deepEqual(classifyModelRef("/Users/me/models/qwen.gguf"), {
|
||||
kind: "local-path",
|
||||
path: "/Users/me/models/qwen.gguf",
|
||||
});
|
||||
});
|
||||
|
||||
test("relative path with ./ → local-path", () => {
|
||||
assert.deepEqual(classifyModelRef("./models/qwen.gguf"), {
|
||||
kind: "local-path",
|
||||
path: "./models/qwen.gguf",
|
||||
});
|
||||
});
|
||||
|
||||
test("home shortcut → local-path", () => {
|
||||
assert.deepEqual(classifyModelRef("~/models/qwen.gguf"), {
|
||||
kind: "local-path",
|
||||
path: "~/models/qwen.gguf",
|
||||
});
|
||||
});
|
||||
|
||||
test(".gguf extension without path prefix → local-path", () => {
|
||||
// Bare filename ending in .gguf — user clearly means a file.
|
||||
assert.deepEqual(classifyModelRef("my-model.gguf"), {
|
||||
kind: "local-path",
|
||||
path: "my-model.gguf",
|
||||
});
|
||||
});
|
||||
|
||||
test("plain name → catalog", () => {
|
||||
assert.deepEqual(classifyModelRef("Qwen3-8B-Q4_K_M"), {
|
||||
kind: "catalog",
|
||||
name: "Qwen3-8B-Q4_K_M",
|
||||
});
|
||||
});
|
||||
|
||||
test("trims whitespace before classifying", () => {
|
||||
assert.deepEqual(classifyModelRef(" Qwen3-8B-Q4_K_M "), {
|
||||
kind: "catalog",
|
||||
name: "Qwen3-8B-Q4_K_M",
|
||||
});
|
||||
});
|
||||
|
||||
// ── modelRefHintLabel ─────────────────────────────────────────────────
|
||||
|
||||
test("hint labels are user-facing strings", () => {
|
||||
assert.equal(
|
||||
modelRefHintLabel({ kind: "catalog", name: "x" }),
|
||||
"Looks like a catalog name",
|
||||
);
|
||||
assert.equal(
|
||||
modelRefHintLabel({ kind: "huggingface", ref: "hf://x" }),
|
||||
"HuggingFace ref",
|
||||
);
|
||||
assert.equal(
|
||||
modelRefHintLabel({ kind: "local-path", path: "/x" }),
|
||||
"Local file",
|
||||
);
|
||||
assert.equal(modelRefHintLabel({ kind: "unknown" }), null);
|
||||
});
|
||||
59
desktop/src/features/mesh-compute/classifyModelRef.ts
Normal file
59
desktop/src/features/mesh-compute/classifyModelRef.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Classification of a free-text model ref entered into the serve card.
|
||||
* UI shows a hint inline ("Looks like a catalog name") for trust feedback.
|
||||
* Mirrors mesh's own resolve logic at `runtime/mod.rs:3390`.
|
||||
*/
|
||||
export type ModelRefKind =
|
||||
| { kind: "catalog"; name: string }
|
||||
| { kind: "huggingface"; ref: string }
|
||||
| { kind: "local-path"; path: string }
|
||||
| { kind: "unknown" };
|
||||
|
||||
/**
|
||||
* Classify a model-ref string the way mesh-llm's runtime does:
|
||||
* - `hf://…` → HuggingFace ref
|
||||
* - starts with `/` or `./` or `~`, OR ends with `.gguf` → local file
|
||||
* - otherwise non-empty → catalog name
|
||||
* - empty/whitespace → unknown
|
||||
*
|
||||
* Source: mesh runtime/mod.rs:3390 ("local file, catalog name, or HuggingFace URL").
|
||||
*
|
||||
* This is presentational only — the canonical resolution still happens server-
|
||||
* side via `mesh_start_node`. UI uses this for the "Looks like a …" hint that
|
||||
* makes the free-text field feel honest instead of opaque.
|
||||
*/
|
||||
export function classifyModelRef(raw: string): ModelRefKind {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return { kind: "unknown" };
|
||||
}
|
||||
if (trimmed.startsWith("hf://")) {
|
||||
return { kind: "huggingface", ref: trimmed };
|
||||
}
|
||||
// Local path heuristics. Conservative: only mark as path when there are
|
||||
// unambiguous signals (leading separator, home shortcut, .gguf extension).
|
||||
const looksLikePath =
|
||||
trimmed.startsWith("/") ||
|
||||
trimmed.startsWith("./") ||
|
||||
trimmed.startsWith("../") ||
|
||||
trimmed.startsWith("~") ||
|
||||
trimmed.toLowerCase().endsWith(".gguf");
|
||||
if (looksLikePath) {
|
||||
return { kind: "local-path", path: trimmed };
|
||||
}
|
||||
return { kind: "catalog", name: trimmed };
|
||||
}
|
||||
|
||||
/** Short label for the inline hint, e.g. "Looks like a catalog name". */
|
||||
export function modelRefHintLabel(kind: ModelRefKind): string | null {
|
||||
switch (kind.kind) {
|
||||
case "catalog":
|
||||
return "Looks like a catalog name";
|
||||
case "huggingface":
|
||||
return "HuggingFace ref";
|
||||
case "local-path":
|
||||
return "Local file";
|
||||
case "unknown":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { meshAvailability } from "@/shared/api/tauriMesh";
|
||||
import type { MeshAvailability } from "@/shared/api/tauriMesh";
|
||||
|
||||
/**
|
||||
* Polls `mesh_availability` at a slow cadence — this drives the Settings
|
||||
* client-mode tile AND the Create-Agent "Relay mesh" flow gate. Both consumers
|
||||
* tolerate up-to-5-second staleness; we re-fetch on focus to keep transitions
|
||||
* (member added/removed, serve node started/stopped) feeling live.
|
||||
*
|
||||
* Returns `null` until the first successful fetch; consumers render a neutral
|
||||
* loading state during that window rather than guessing at false defaults.
|
||||
*/
|
||||
export function useMeshAvailability(): {
|
||||
availability: MeshAvailability | null;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
} {
|
||||
const [availability, setAvailability] =
|
||||
React.useState<MeshAvailability | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
// Single fetch routine; the latest one wins via the `cancelled` flag captured
|
||||
// at call time. Returning the cleanup lets useEffect / setInterval invalidate
|
||||
// in-flight fetches when they unmount or super-cede each other.
|
||||
const fetchOnce = React.useCallback(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const value = await meshAvailability();
|
||||
if (!cancelled) {
|
||||
setAvailability(value);
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Initial fetch on mount.
|
||||
React.useEffect(() => fetchOnce(), [fetchOnce]);
|
||||
|
||||
// Slow polling — availability changes when members are admitted/removed or
|
||||
// serve nodes come up/down, which is human-scale, not sub-second.
|
||||
React.useEffect(() => {
|
||||
const handle = window.setInterval(() => {
|
||||
fetchOnce();
|
||||
}, 5000);
|
||||
return () => window.clearInterval(handle);
|
||||
}, [fetchOnce]);
|
||||
|
||||
// Refresh when window regains focus — common case is user toggled
|
||||
// share-compute on a peer machine and tabbed back.
|
||||
React.useEffect(() => {
|
||||
const onFocus = () => {
|
||||
fetchOnce();
|
||||
};
|
||||
window.addEventListener("focus", onFocus);
|
||||
return () => window.removeEventListener("focus", onFocus);
|
||||
}, [fetchOnce]);
|
||||
|
||||
return { availability, error, refresh: fetchOnce };
|
||||
}
|
||||
58
desktop/src/features/mesh-compute/hooks/useMeshNodeStatus.ts
Normal file
58
desktop/src/features/mesh-compute/hooks/useMeshNodeStatus.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { meshNodeStatus } from "@/shared/api/tauriMesh";
|
||||
import type { MeshNodeStatus } from "@/shared/api/tauriMesh";
|
||||
|
||||
/**
|
||||
* Polls `mesh_node_status` faster than availability — lifecycle transitions
|
||||
* (off → starting → running, or running → failed) need to render quickly so
|
||||
* the Share-compute card doesn't show a frozen "Starting…" for minutes.
|
||||
*
|
||||
* The poll interval steps up while transitioning and steps down when steady.
|
||||
* That avoids hammering the runtime once a node is just "running ok."
|
||||
*
|
||||
* Returns `null` until first successful fetch.
|
||||
*/
|
||||
export function useMeshNodeStatus(): {
|
||||
status: MeshNodeStatus | null;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
} {
|
||||
const [status, setStatus] = React.useState<MeshNodeStatus | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const fetchOnce = React.useCallback(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const value = await meshNodeStatus();
|
||||
if (!cancelled) {
|
||||
setStatus(value);
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
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;
|
||||
const handle = window.setInterval(() => {
|
||||
fetchOnce();
|
||||
}, interval);
|
||||
return () => window.clearInterval(handle);
|
||||
}, [status?.state, fetchOnce]);
|
||||
|
||||
return { status, error, refresh: fetchOnce };
|
||||
}
|
||||
307
desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx
Normal file
307
desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import * as React from "react";
|
||||
import { ChevronDown, Cpu } from "lucide-react";
|
||||
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Switch } from "@/shared/ui/switch";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
import {
|
||||
meshStartNode,
|
||||
meshStopNode,
|
||||
meshInstalledModels,
|
||||
} from "@/shared/api/tauriMesh";
|
||||
import type { MeshModelOption, MeshNodeStatus } from "@/shared/api/tauriMesh";
|
||||
import { classifyModelRef, modelRefHintLabel } from "../classifyModelRef";
|
||||
import { useMeshNodeStatus } from "../hooks/useMeshNodeStatus";
|
||||
|
||||
/**
|
||||
* Settings → Compute → Share compute.
|
||||
*
|
||||
* One toggle, one model field, an "Already installed" picklist, an Advanced
|
||||
* group. Honest copy throughout — no kind:30621, no "endpoint id", no raw
|
||||
* mesh knobs.
|
||||
*
|
||||
* The architectural-trust footer is load-bearing: it tells a privacy-aware
|
||||
* user that *not publishing* is enforced by the build, not by a default they
|
||||
* have to verify. (Source of the invariants this copy claims: Max's no-leak
|
||||
* builder defaults — publish=false, no Nostr relays, no auto-discovery.)
|
||||
*/
|
||||
export function MeshComputeSettingsCard() {
|
||||
const { status, error, refresh } = useMeshNodeStatus();
|
||||
const [installedModels, setInstalledModels] = React.useState<
|
||||
MeshModelOption[]
|
||||
>([]);
|
||||
const [modelInput, setModelInput] = React.useState("");
|
||||
const [maxVramGb, setMaxVramGb] = React.useState<string>("");
|
||||
const [advancedOpen, setAdvancedOpen] = React.useState(false);
|
||||
const [actionInFlight, setActionInFlight] = React.useState(false);
|
||||
const [actionError, setActionError] = React.useState<string | null>(null);
|
||||
|
||||
// Fetch installed models. Called on mount and whenever the running state
|
||||
// changes (a fresh start may have downloaded a new model). Stale-tolerant —
|
||||
// the picklist is a convenience, not load-bearing.
|
||||
const refreshInstalled = React.useCallback(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const list = await meshInstalledModels();
|
||||
if (!cancelled) setInstalledModels(list);
|
||||
} catch {
|
||||
// Non-fatal — picklist just stays empty; user can still type a ref.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: status?.state is the intentional trigger — re-fetch installed models when the node transitions (a fresh start may have downloaded a new model)
|
||||
React.useEffect(() => refreshInstalled(), [refreshInstalled, status?.state]);
|
||||
|
||||
// Mirror the running node's modelId back into the field so the card shows
|
||||
// what's actually being served, even after a fresh app load.
|
||||
React.useEffect(() => {
|
||||
if (status?.state === "running" && status.modelId && modelInput === "") {
|
||||
setModelInput(status.modelId);
|
||||
}
|
||||
}, [status?.state, status?.modelId, modelInput]);
|
||||
|
||||
const isOn = status?.state === "running" || status?.state === "starting";
|
||||
const refClass = classifyModelRef(modelInput);
|
||||
const refHint = modelRefHintLabel(refClass);
|
||||
const canStart =
|
||||
refClass.kind !== "unknown" &&
|
||||
!actionInFlight &&
|
||||
status?.state !== "starting";
|
||||
|
||||
async function handleToggle(next: boolean) {
|
||||
setActionError(null);
|
||||
setActionInFlight(true);
|
||||
try {
|
||||
if (next) {
|
||||
const maxVram =
|
||||
maxVramGb.trim() === "" ? undefined : Number.parseFloat(maxVramGb);
|
||||
await meshStartNode({
|
||||
mode: "serve",
|
||||
modelId: modelInput.trim() || undefined,
|
||||
maxVramGb:
|
||||
typeof maxVram === "number" && !Number.isNaN(maxVram)
|
||||
? maxVram
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
await meshStopNode();
|
||||
}
|
||||
refresh();
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setActionInFlight(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="min-w-0" data-testid="settings-mesh-share-compute">
|
||||
<div className="mb-3 min-w-0">
|
||||
<h2 className="text-sm font-semibold tracking-tight">Share compute</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Share this machine with your relay. When on, other members can run
|
||||
their agents here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="mb-3 rounded bg-destructive/10 px-2 py-1 text-xs text-destructive">
|
||||
Couldn't load mesh status: {error}
|
||||
</p>
|
||||
) : null}
|
||||
{actionError ? (
|
||||
<p className="mb-3 rounded bg-destructive/10 px-2 py-1 text-xs text-destructive">
|
||||
{actionError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* ── Master toggle + status row ─────────────────────────────── */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="mesh-share-compute-toggle"
|
||||
>
|
||||
Share this machine
|
||||
</label>
|
||||
<StatusLine status={status} />
|
||||
</div>
|
||||
<Switch
|
||||
checked={isOn}
|
||||
data-testid="mesh-share-compute-toggle"
|
||||
disabled={actionInFlight || (!isOn && !canStart)}
|
||||
id="mesh-share-compute-toggle"
|
||||
onCheckedChange={handleToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Model field ──────────────────────────────────────────── */}
|
||||
<fieldset
|
||||
className="flex flex-col gap-2"
|
||||
disabled={isOn || actionInFlight}
|
||||
>
|
||||
<legend className="flex items-center gap-2 text-sm font-medium">
|
||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||
Model
|
||||
</legend>
|
||||
<Input
|
||||
data-testid="mesh-share-compute-model"
|
||||
onChange={(e) => setModelInput(e.target.value)}
|
||||
placeholder="Qwen3-8B-Q4_K_M or hf://meshllm/qwen3-8b@main"
|
||||
value={modelInput}
|
||||
/>
|
||||
{refHint ? (
|
||||
<p className="text-xs text-muted-foreground">{refHint}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Catalog name, HuggingFace ref, or a local file path.
|
||||
</p>
|
||||
)}
|
||||
{installedModels.length > 0 ? (
|
||||
<div className="mt-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Already installed on this machine:
|
||||
</p>
|
||||
<ul
|
||||
className="mt-1 flex flex-wrap gap-1.5"
|
||||
data-testid="mesh-share-compute-installed-list"
|
||||
>
|
||||
{installedModels.map((m) => (
|
||||
<li key={m.id}>
|
||||
<button
|
||||
className="rounded border border-border/60 bg-muted/20 px-2 py-0.5 text-xs hover:bg-muted/40"
|
||||
onClick={() => setModelInput(m.id)}
|
||||
type="button"
|
||||
>
|
||||
{m.name ?? m.id}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</fieldset>
|
||||
|
||||
{/* ── Advanced ─────────────────────────────────────────────── */}
|
||||
<details
|
||||
className="rounded border border-border/40 px-3 py-2"
|
||||
onToggle={(e) =>
|
||||
setAdvancedOpen((e.target as HTMLDetailsElement).open)
|
||||
}
|
||||
open={advancedOpen}
|
||||
>
|
||||
<summary className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-3 w-3 transition-transform",
|
||||
advancedOpen ? "rotate-0" : "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
Advanced
|
||||
</summary>
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
<label
|
||||
className="text-xs text-muted-foreground"
|
||||
htmlFor="mesh-vram"
|
||||
>
|
||||
Max VRAM (GB)
|
||||
</label>
|
||||
<Input
|
||||
data-testid="mesh-share-compute-vram"
|
||||
id="mesh-vram"
|
||||
inputMode="decimal"
|
||||
onChange={(e) => setMaxVramGb(e.target.value)}
|
||||
placeholder="No limit"
|
||||
value={maxVramGb}
|
||||
/>
|
||||
{status?.consoleUrl ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Debug console:{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href={status.consoleUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{status.consoleUrl}
|
||||
</a>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* ── Architectural-trust footer ───────────────────────────── */}
|
||||
<p className="rounded bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
Sprout will not publish your machine to public Nostr relays,
|
||||
auto-discover other networks, or share your endpoint outside this
|
||||
relay's members. Only members of this relay can dial in.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the lifecycle/health text under the toggle. Maps Max's `state` ×
|
||||
* `health` matrix to honest copy — no "starting…" stuck forever when mesh
|
||||
* is actually downloading weights or has failed.
|
||||
*/
|
||||
function StatusLine({ status }: { status: MeshNodeStatus | null }) {
|
||||
if (!status) {
|
||||
return <p className="text-sm text-muted-foreground">Loading…</p>;
|
||||
}
|
||||
const { state, health, modelId, modelName } = status;
|
||||
const modelLabel = modelName ?? modelId ?? "";
|
||||
|
||||
if (state === "off") {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">Not sharing right now.</p>
|
||||
);
|
||||
}
|
||||
if (state === "starting") {
|
||||
const reason =
|
||||
health.status === "degraded" || health.status === "failed"
|
||||
? health.reason
|
||||
: "Starting…";
|
||||
return <p className="text-sm text-muted-foreground">{reason}</p>;
|
||||
}
|
||||
if (state === "running") {
|
||||
if (health.status === "failed") {
|
||||
return (
|
||||
<p className="text-sm text-destructive">
|
||||
Couldn't load: {health.reason}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (health.status === "degraded") {
|
||||
return (
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400">
|
||||
Active{modelLabel ? ` — ${modelLabel}` : ""}. {health.reason}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Active{modelLabel ? ` — serving ${modelLabel}` : ""}.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (state === "stopping") {
|
||||
return <p className="text-sm text-muted-foreground">Stopping…</p>;
|
||||
}
|
||||
if (state === "failed") {
|
||||
const reason =
|
||||
health.status === "failed" || health.status === "degraded"
|
||||
? health.reason
|
||||
: "Couldn't start.";
|
||||
return <p className="text-sm text-destructive">{reason}</p>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
155
desktop/src/features/mesh-compute/ui/RelayMeshAgentSection.tsx
Normal file
155
desktop/src/features/mesh-compute/ui/RelayMeshAgentSection.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import * as React from "react";
|
||||
import { AlertCircle, Network } from "lucide-react";
|
||||
|
||||
import { meshAgentPreset } from "@/shared/api/tauriMesh";
|
||||
import { Switch } from "@/shared/ui/switch";
|
||||
|
||||
import {
|
||||
detectMeshPresetOverrides,
|
||||
meshAgentPresetPatch,
|
||||
} from "../applyMeshAgentPreset";
|
||||
import { useMeshAvailability } from "../hooks/useMeshAvailability";
|
||||
|
||||
/**
|
||||
* The "Run on relay mesh" entry inside CreateAgentDialog. When enabled, the
|
||||
* user picks a model from `mesh_availability().models`, the dialog's runtime
|
||||
* + env-var state is fanned out from `mesh_agent_preset()`, and the existing
|
||||
* runtime/backend pickers are hidden in the parent.
|
||||
*
|
||||
* Lives outside `CreateAgentDialog` so the dialog file's diff stays narrow —
|
||||
* the dialog only renders this and reacts to its `onPatch` and `useMesh`
|
||||
* callbacks. The dialog is the single source of truth for state; this
|
||||
* component is purely a controller for the mesh-specific subset.
|
||||
*/
|
||||
export function RelayMeshAgentSection({
|
||||
current,
|
||||
useMesh,
|
||||
modelId,
|
||||
onUseMeshChange,
|
||||
onModelIdChange,
|
||||
}: {
|
||||
/**
|
||||
* Current draft state of the *fields the preset would overwrite*. Used to
|
||||
* compute the override warning ("Using Relay mesh — overrides this
|
||||
* persona's model") and to know what changed.
|
||||
*/
|
||||
current: {
|
||||
acpCommand: string;
|
||||
agentCommand: string;
|
||||
agentArgs: string[];
|
||||
mcpCommand: string;
|
||||
model: string | null;
|
||||
envVars: Record<string, string>;
|
||||
};
|
||||
useMesh: boolean;
|
||||
modelId: string;
|
||||
onUseMeshChange: (next: boolean) => void;
|
||||
/**
|
||||
* Fires when the user picks a model. The parent should fan out the
|
||||
* preset to its individual setters (acpCommand, envVars, etc.) using
|
||||
* `meshAgentPresetPatch`. We expose both `modelId` and the resolved patch
|
||||
* so the parent can choose either eager fan-out or lazy-apply on submit.
|
||||
*/
|
||||
onModelIdChange: (
|
||||
nextModelId: string,
|
||||
patch: ReturnType<typeof meshAgentPresetPatch> | null,
|
||||
) => void;
|
||||
}) {
|
||||
const { availability, error } = useMeshAvailability();
|
||||
const [presetError, setPresetError] = React.useState<string | null>(null);
|
||||
|
||||
const disabled = availability == null || !availability.available;
|
||||
const disabledReason =
|
||||
availability == null
|
||||
? (error ?? "Checking relay mesh availability…")
|
||||
: (availability.reason ?? "Relay compute isn't available right now.");
|
||||
|
||||
// Compute overrides from the currently-selected model's preset, *not* from
|
||||
// an arbitrary one — the warning must reflect what'll actually happen.
|
||||
const [overrides, setOverrides] = React.useState<string[]>([]);
|
||||
|
||||
async function pickModel(nextModelId: string) {
|
||||
if (nextModelId === "") {
|
||||
onModelIdChange("", null);
|
||||
setOverrides([]);
|
||||
setPresetError(null);
|
||||
return;
|
||||
}
|
||||
setPresetError(null);
|
||||
try {
|
||||
const preset = await meshAgentPreset(nextModelId);
|
||||
const patch = meshAgentPresetPatch(preset);
|
||||
setOverrides(detectMeshPresetOverrides(current, preset));
|
||||
onModelIdChange(nextModelId, patch);
|
||||
} catch (err) {
|
||||
setPresetError(err instanceof Error ? err.message : String(err));
|
||||
onModelIdChange(nextModelId, null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-2xl border border-border/70 bg-muted/10 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<label
|
||||
className="flex items-center gap-1.5 text-sm font-medium"
|
||||
htmlFor="agent-relay-mesh-toggle"
|
||||
>
|
||||
<Network className="h-4 w-4 text-muted-foreground" />
|
||||
Run on relay mesh
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{disabled
|
||||
? disabledReason
|
||||
: "Use a member's shared compute — no API key needed."}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={useMesh}
|
||||
data-testid="agent-relay-mesh-toggle"
|
||||
disabled={disabled}
|
||||
id="agent-relay-mesh-toggle"
|
||||
onCheckedChange={onUseMeshChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{useMesh ? (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
className="text-xs text-muted-foreground"
|
||||
htmlFor="agent-relay-mesh-model"
|
||||
>
|
||||
Model
|
||||
</label>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs"
|
||||
data-testid="agent-relay-mesh-model"
|
||||
id="agent-relay-mesh-model"
|
||||
onChange={(e) => void pickModel(e.target.value)}
|
||||
value={modelId}
|
||||
>
|
||||
<option value="">Choose a model…</option>
|
||||
{availability?.models.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.name ?? model.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{presetError ? (
|
||||
<p className="rounded bg-destructive/10 px-2 py-1 text-xs text-destructive">
|
||||
Couldn't load model preset: {presetError}
|
||||
</p>
|
||||
) : null}
|
||||
{overrides.length > 0 ? (
|
||||
<p className="flex items-start gap-1.5 rounded bg-warning-bg/60 px-2 py-1.5 text-xs text-warning">
|
||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
Using Relay mesh overrides this agent's {overrides.join(", ")}.
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
BellRing,
|
||||
Bot,
|
||||
Check,
|
||||
Cpu,
|
||||
Download,
|
||||
Keyboard,
|
||||
LayoutTemplate,
|
||||
@@ -31,6 +32,7 @@ import { SYNTAX_THEMES, isLightTheme } from "@/shared/theme/theme-loader";
|
||||
import { ChannelTemplatesSettingsCard } from "./ChannelTemplatesSettingsCard";
|
||||
import { DoctorSettingsPanel } from "./DoctorSettingsPanel";
|
||||
import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard";
|
||||
import { MeshComputeSettingsCard } from "@/features/mesh-compute/ui/MeshComputeSettingsCard";
|
||||
import { MobilePairingCard } from "./MobilePairingCard";
|
||||
import { NotificationSettingsCard } from "./NotificationSettingsCard";
|
||||
import { PreventSleepSettingsCard } from "./PreventSleepSettingsCard";
|
||||
@@ -42,6 +44,7 @@ export type SettingsSection =
|
||||
| "notifications"
|
||||
| "agents"
|
||||
| "channel-templates"
|
||||
| "compute"
|
||||
| "appearance"
|
||||
| "shortcuts"
|
||||
| "relay-members"
|
||||
@@ -92,6 +95,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [
|
||||
label: "Templates",
|
||||
icon: LayoutTemplate,
|
||||
},
|
||||
{
|
||||
value: "compute",
|
||||
label: "Compute",
|
||||
icon: Cpu,
|
||||
},
|
||||
{
|
||||
value: "appearance",
|
||||
label: "Appearance",
|
||||
@@ -284,6 +292,8 @@ export function renderSettingsSection(
|
||||
return <PreventSleepSettingsCard />;
|
||||
case "channel-templates":
|
||||
return <ChannelTemplatesSettingsCard />;
|
||||
case "compute":
|
||||
return <MeshComputeSettingsCard />;
|
||||
case "appearance":
|
||||
return <ThemeSettingsCard />;
|
||||
case "shortcuts":
|
||||
|
||||
104
desktop/src/shared/api/tauriMesh.ts
Normal file
104
desktop/src/shared/api/tauriMesh.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { invokeTauri } from "./tauri";
|
||||
|
||||
export type MeshHealth =
|
||||
| { status: "ok"; reason?: null }
|
||||
| { status: "degraded" | "failed"; reason: string };
|
||||
|
||||
export type MeshModelOption = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
};
|
||||
|
||||
export type MeshServeTarget = {
|
||||
modelId: string;
|
||||
modelName: string | null;
|
||||
endpointAddr: string;
|
||||
nodeName: string | null;
|
||||
capacity: { vramGb: number | null } | null;
|
||||
};
|
||||
|
||||
export type MeshAvailability = {
|
||||
capable: boolean;
|
||||
admitted: boolean;
|
||||
available: boolean;
|
||||
reason: string | null;
|
||||
models: MeshModelOption[];
|
||||
serveTargets: MeshServeTarget[];
|
||||
};
|
||||
|
||||
export type MeshNodeState =
|
||||
| "off"
|
||||
| "starting"
|
||||
| "running"
|
||||
| "stopping"
|
||||
| "failed";
|
||||
export type MeshNodeMode = "serve" | "client";
|
||||
|
||||
export type StartMeshNodeRequest = {
|
||||
mode: MeshNodeMode;
|
||||
modelId?: string;
|
||||
maxVramGb?: number;
|
||||
joinToken?: string;
|
||||
irohRelayUrl?: string;
|
||||
irohRelayAuth?: string;
|
||||
};
|
||||
|
||||
export type MeshNodeStatus = {
|
||||
state: MeshNodeState;
|
||||
mode: MeshNodeMode | null;
|
||||
health: MeshHealth;
|
||||
apiBaseUrl: string | null;
|
||||
consoleUrl: string | null;
|
||||
modelId: string | null;
|
||||
modelName: string | null;
|
||||
inviteToken?: string | null;
|
||||
};
|
||||
|
||||
export type MeshAgentPreset = {
|
||||
providerId: "relay-mesh";
|
||||
label: string;
|
||||
acpCommand: string;
|
||||
agentCommand: string;
|
||||
agentArgs: string[];
|
||||
mcpCommand: string;
|
||||
model: string;
|
||||
envVars: Record<string, string>;
|
||||
};
|
||||
|
||||
export async function meshAvailability(): Promise<MeshAvailability> {
|
||||
return await invokeTauri<MeshAvailability>("mesh_availability");
|
||||
}
|
||||
|
||||
export async function meshStartNode(
|
||||
request: StartMeshNodeRequest,
|
||||
): Promise<MeshNodeStatus> {
|
||||
return await invokeTauri<MeshNodeStatus>("mesh_start_node", { request });
|
||||
}
|
||||
|
||||
export async function meshEnsureClientNode(
|
||||
modelId: string,
|
||||
): Promise<MeshNodeStatus> {
|
||||
return await invokeTauri<MeshNodeStatus>("mesh_ensure_client_node", {
|
||||
request: { modelId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function meshStopNode(): Promise<MeshNodeStatus> {
|
||||
return await invokeTauri<MeshNodeStatus>("mesh_stop_node");
|
||||
}
|
||||
|
||||
export async function meshNodeStatus(): Promise<MeshNodeStatus> {
|
||||
return await invokeTauri<MeshNodeStatus>("mesh_node_status");
|
||||
}
|
||||
|
||||
export async function meshInstalledModels(): Promise<MeshModelOption[]> {
|
||||
return await invokeTauri<MeshModelOption[]>("mesh_installed_models");
|
||||
}
|
||||
|
||||
export async function meshAgentPreset(
|
||||
modelId: string,
|
||||
): Promise<MeshAgentPreset> {
|
||||
return await invokeTauri<MeshAgentPreset>("mesh_agent_preset", {
|
||||
request: { modelId },
|
||||
});
|
||||
}
|
||||
@@ -464,6 +464,11 @@ declare global {
|
||||
) => Promise<unknown>;
|
||||
__SPROUT_E2E_PUSH_MOCK_FEED_ITEM__?: (item: RawFeedItem) => RawFeedItem;
|
||||
__SPROUT_E2E_SET_STALL_WEBSOCKET_SENDS__?: (stall: boolean) => void;
|
||||
__SPROUT_E2E_SET_MESH__?: (mesh: {
|
||||
admitted?: boolean;
|
||||
models?: Array<{ id: string; name: string | null }>;
|
||||
denyReason?: string;
|
||||
}) => void;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1112,6 +1117,41 @@ const mockSockets = new Map<number, MockSocket>();
|
||||
let mockWebsocketSendMutexWedged = false;
|
||||
const realSockets = new Map<number, WebSocket>();
|
||||
let mockManagedAgents: MockManagedAgent[] = [];
|
||||
|
||||
// Mesh-compute mock state — TEST-ONLY.
|
||||
//
|
||||
// This entire module (e2eBridge.ts) is loaded only when `window.__SPROUT_E2E__`
|
||||
// is set by the Playwright harness; it never runs in a shipped build. These
|
||||
// handlers stub the `mesh_*` Tauri commands with the SHAPES the UI expects
|
||||
// (availability, node status, preset) so the desktop UI flow can be exercised
|
||||
// in a browser. They deliberately do NOT model real admission, real inference,
|
||||
// or real mesh routing — those are proven by the Rust layer-2 tests and the
|
||||
// on-hardware layer-1 example. Do not port any of this into production code.
|
||||
const mockMeshState: {
|
||||
admitted: boolean;
|
||||
models: Array<{ id: string; name: string | null }>;
|
||||
denyReason: string;
|
||||
nodeState: "off" | "running";
|
||||
nodeMode: "serve" | "client" | null;
|
||||
} = {
|
||||
admitted: true,
|
||||
models: [
|
||||
{ id: "hf://demo/SmolLM2-135M-Instruct-GGUF:Q4_K_M", name: "SmolLM2 135M" },
|
||||
],
|
||||
denyReason: "not a relay member",
|
||||
nodeState: "off",
|
||||
nodeMode: null,
|
||||
};
|
||||
|
||||
function resetMockMesh() {
|
||||
mockMeshState.admitted = true;
|
||||
mockMeshState.models = [
|
||||
{ id: "hf://demo/SmolLM2-135M-Instruct-GGUF:Q4_K_M", name: "SmolLM2 135M" },
|
||||
];
|
||||
mockMeshState.denyReason = "not a relay member";
|
||||
mockMeshState.nodeState = "off";
|
||||
mockMeshState.nodeMode = null;
|
||||
}
|
||||
let mockPersonas: RawPersona[] = [];
|
||||
let mockTeams: RawTeam[] = [];
|
||||
let mockRelayAgents: RawRelayAgent[] = [
|
||||
@@ -4736,6 +4776,7 @@ export function maybeInstallE2eTauriMocks() {
|
||||
resetMockPersonas();
|
||||
resetMockTeams();
|
||||
resetMockWorkflows();
|
||||
resetMockMesh();
|
||||
mockWebsocketSendMutexWedged = false;
|
||||
mockWindows("main");
|
||||
window.__SPROUT_E2E_COMMANDS__ = [];
|
||||
@@ -4797,12 +4838,86 @@ export function maybeInstallE2eTauriMocks() {
|
||||
config.mock.stallWebsocketSends = stall;
|
||||
if (!stall) mockWebsocketSendMutexWedged = false;
|
||||
};
|
||||
// Tests flip `admitted` to exercise the denial path: mesh_ensure_client_node
|
||||
// rejects when not admitted, which proves relay membership is the gate and
|
||||
// that the create flow surfaces denial copy without spawning the agent.
|
||||
window.__SPROUT_E2E_SET_MESH__ = (mesh) => {
|
||||
if (mesh.admitted !== undefined) mockMeshState.admitted = mesh.admitted;
|
||||
if (mesh.models !== undefined) mockMeshState.models = mesh.models;
|
||||
if (mesh.denyReason !== undefined)
|
||||
mockMeshState.denyReason = mesh.denyReason;
|
||||
};
|
||||
const meshNodeStatus = (
|
||||
state: "off" | "running",
|
||||
mode: "serve" | "client" | null,
|
||||
) => ({
|
||||
state,
|
||||
mode,
|
||||
health: { status: "ok" as const, reason: null },
|
||||
apiBaseUrl: state === "running" ? "http://127.0.0.1:9337/v1" : null,
|
||||
consoleUrl: null,
|
||||
modelId: mockMeshState.models[0]?.id ?? null,
|
||||
modelName: mockMeshState.models[0]?.name ?? null,
|
||||
inviteToken: null,
|
||||
});
|
||||
const handleMockCommand = async (command: string, payload: unknown) => {
|
||||
const activeConfig = getConfig();
|
||||
const identity = getActiveIdentity(activeConfig);
|
||||
window.__SPROUT_E2E_COMMANDS__?.push(command);
|
||||
|
||||
switch (command) {
|
||||
case "mesh_availability":
|
||||
return {
|
||||
capable: true,
|
||||
admitted: mockMeshState.admitted,
|
||||
available: mockMeshState.admitted,
|
||||
reason: mockMeshState.admitted ? null : mockMeshState.denyReason,
|
||||
models: mockMeshState.models,
|
||||
serveTargets: [],
|
||||
};
|
||||
case "mesh_installed_models":
|
||||
return mockMeshState.models;
|
||||
case "mesh_node_status":
|
||||
return meshNodeStatus(mockMeshState.nodeState, mockMeshState.nodeMode);
|
||||
case "mesh_start_node": {
|
||||
const req = (
|
||||
payload as { request?: { mode?: "serve" | "client" } } | null
|
||||
)?.request;
|
||||
mockMeshState.nodeState = "running";
|
||||
mockMeshState.nodeMode = req?.mode ?? "serve";
|
||||
return meshNodeStatus(mockMeshState.nodeState, mockMeshState.nodeMode);
|
||||
}
|
||||
case "mesh_stop_node":
|
||||
mockMeshState.nodeState = "off";
|
||||
mockMeshState.nodeMode = null;
|
||||
return meshNodeStatus("off", null);
|
||||
case "mesh_ensure_client_node":
|
||||
// The invariant under test: membership is the only factor. A
|
||||
// non-admitted (non-member) caller cannot bring up the client node,
|
||||
// and no extra manual auth step exists — admission alone decides.
|
||||
if (!mockMeshState.admitted) {
|
||||
throw new Error(mockMeshState.denyReason);
|
||||
}
|
||||
return meshNodeStatus("running", "client");
|
||||
case "mesh_agent_preset": {
|
||||
const req = (payload as { request?: { modelId?: string } } | null)
|
||||
?.request;
|
||||
const model = req?.modelId ?? mockMeshState.models[0]?.id ?? "";
|
||||
return {
|
||||
providerId: "relay-mesh" as const,
|
||||
label: "Run on relay mesh",
|
||||
acpCommand: "",
|
||||
agentCommand: "sprout-agent",
|
||||
agentArgs: [],
|
||||
mcpCommand: "",
|
||||
model,
|
||||
envVars: {
|
||||
SPROUT_AGENT_PROVIDER: "openai",
|
||||
OPENAI_COMPAT_BASE_URL: "http://127.0.0.1:9337/v1",
|
||||
OPENAI_COMPAT_MODEL: model,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "get_identity":
|
||||
if (identity) {
|
||||
return {
|
||||
|
||||
157
desktop/tests/e2e/mesh-compute.spec.ts
Normal file
157
desktop/tests/e2e/mesh-compute.spec.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
import { openSettings } from "../helpers/settings";
|
||||
|
||||
// Layer 3 of the mesh e2e: the desktop UI contract for mesh-compute, driven
|
||||
// through the real UI + the (mocked) Tauri mesh commands. Asserts the bridge
|
||||
// CALL ORDER, not just labels — in particular the ensure-before-spawn
|
||||
// invariant and the membership-denial copy.
|
||||
|
||||
type E2eWindow = Window & {
|
||||
__SPROUT_E2E_INVOKE_MOCK_COMMAND__?: unknown;
|
||||
__TAURI_INTERNALS__?: { invoke?: unknown };
|
||||
__SPROUT_E2E_COMMANDS__?: string[];
|
||||
__SPROUT_E2E_SET_MESH__?: (mesh: {
|
||||
admitted?: boolean;
|
||||
models?: Array<{ id: string; name: string | null }>;
|
||||
denyReason?: string;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
async function waitForInvokeBridge(page: import("@playwright/test").Page) {
|
||||
await page.waitForFunction(() => {
|
||||
const w = window as E2eWindow;
|
||||
return (
|
||||
typeof w.__SPROUT_E2E_INVOKE_MOCK_COMMAND__ === "function" ||
|
||||
typeof w.__TAURI_INTERNALS__?.invoke === "function"
|
||||
);
|
||||
}, null);
|
||||
}
|
||||
|
||||
async function gotoApp(page: import("@playwright/test").Page) {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await waitForInvokeBridge(page);
|
||||
await expect(page.getByTestId("open-agents-view")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Ordered command names the bridge recorded so far. */
|
||||
async function commands(page: import("@playwright/test").Page) {
|
||||
return page.evaluate(
|
||||
() => (window as E2eWindow).__SPROUT_E2E_COMMANDS__ ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
async function setMesh(
|
||||
page: import("@playwright/test").Page,
|
||||
mesh: { admitted?: boolean; denyReason?: string },
|
||||
) {
|
||||
await page.evaluate((m) => {
|
||||
(window as E2eWindow).__SPROUT_E2E_SET_MESH__?.(m);
|
||||
}, mesh);
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
});
|
||||
|
||||
test("Share compute starts and stops a serve node", async ({ page }) => {
|
||||
await gotoApp(page);
|
||||
await openSettings(page, "compute");
|
||||
|
||||
const card = page.getByTestId("settings-mesh-share-compute");
|
||||
await expect(card).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// A model ref is required before the machine can be shared (the toggle IS
|
||||
// the start/stop control).
|
||||
await page
|
||||
.getByTestId("mesh-share-compute-model")
|
||||
.fill("hf://demo/SmolLM2-135M-Instruct-GGUF:Q4_K_M");
|
||||
|
||||
const toggle = page.getByTestId("mesh-share-compute-toggle");
|
||||
await expect(toggle).toBeEnabled({ timeout: 10_000 });
|
||||
|
||||
// Toggle on -> starts a serve node.
|
||||
await toggle.click();
|
||||
await expect
|
||||
.poll(async () => await commands(page))
|
||||
.toContain("mesh_start_node");
|
||||
|
||||
// Status polling flips the toggle into the "on" state…
|
||||
await expect(toggle).toBeChecked({ timeout: 10_000 });
|
||||
|
||||
// …toggle off -> stops the serve node.
|
||||
await toggle.click();
|
||||
await expect
|
||||
.poll(async () => await commands(page))
|
||||
.toContain("mesh_stop_node");
|
||||
});
|
||||
|
||||
test("Run-on-relay-mesh ensures the client node BEFORE spawning the agent", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoApp(page);
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
await page.getByRole("button", { name: "New" }).click();
|
||||
await page.getByText("Custom Agent").click();
|
||||
|
||||
// Member is admitted -> the relay-mesh toggle becomes enabled (availability
|
||||
// resolves to available). Wait for that before driving the flow.
|
||||
await page.getByTestId("agent-name-input").fill("Mesh Agent");
|
||||
|
||||
const toggle = page.getByTestId("agent-relay-mesh-toggle");
|
||||
await expect(toggle).toBeEnabled({ timeout: 10_000 });
|
||||
await toggle.click();
|
||||
await page
|
||||
.getByTestId("agent-relay-mesh-model")
|
||||
.selectOption("hf://demo/SmolLM2-135M-Instruct-GGUF:Q4_K_M");
|
||||
|
||||
await expect(page.getByTestId("create-agent-submit")).toBeEnabled({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// Snapshot the command log length right before the click so we assert the
|
||||
// ensure→spawn ordering WITHIN this user action's fresh slice, not merely
|
||||
// that ensure appeared somewhere earlier (e.g. an availability probe).
|
||||
const before = (await commands(page)).length;
|
||||
await page.getByTestId("create-agent-submit").click();
|
||||
|
||||
await expect
|
||||
.poll(async () => (await commands(page)).slice(before))
|
||||
.toContain("create_managed_agent");
|
||||
|
||||
// The invariant: within the Create action, ensure runs BEFORE create.
|
||||
const slice = (await commands(page)).slice(before);
|
||||
const ensureIdx = slice.indexOf("mesh_ensure_client_node");
|
||||
const createIdx = slice.indexOf("create_managed_agent");
|
||||
expect(
|
||||
ensureIdx,
|
||||
"ensure must occur in the Create action",
|
||||
).toBeGreaterThanOrEqual(0);
|
||||
expect(ensureIdx).toBeLessThan(createIdx);
|
||||
});
|
||||
|
||||
test("a non-member cannot enable relay-mesh — membership is the gate", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoApp(page);
|
||||
// Non-member: relay membership is the only factor, so availability reports
|
||||
// unavailable. There is no manual auth action that would change this.
|
||||
await setMesh(page, { admitted: false, denyReason: "not a relay member" });
|
||||
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
await page.getByRole("button", { name: "New" }).click();
|
||||
await page.getByText("Custom Agent").click();
|
||||
|
||||
// The relay-mesh toggle stays disabled — a non-member cannot even opt into
|
||||
// running on the mesh, let alone spawn an agent against it.
|
||||
const toggle = page.getByTestId("agent-relay-mesh-toggle");
|
||||
await expect(toggle).toBeDisabled();
|
||||
|
||||
// The flow never reaches an ensure-or-spawn, because membership gates the
|
||||
// entry point itself. Sanity-check we never created an agent on the mesh.
|
||||
const seq = await commands(page);
|
||||
expect(seq).not.toContain("create_managed_agent");
|
||||
});
|
||||
@@ -4,6 +4,8 @@ type SettingsSection =
|
||||
| "profile"
|
||||
| "notifications"
|
||||
| "agents"
|
||||
| "channel-templates"
|
||||
| "compute"
|
||||
| "appearance"
|
||||
| "shortcuts"
|
||||
| "tokens"
|
||||
|
||||
104
docs/mesh-llm-local-build.md
Normal file
104
docs/mesh-llm-local-build.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Mesh LLM local build prerequisites
|
||||
|
||||
Sprout embeds mesh-llm through the Rust SDK pinned in Cargo. mesh-llm's native
|
||||
skippy/llama layer is linked into the relay and desktop binaries.
|
||||
|
||||
## Local Mac demo path
|
||||
|
||||
For the first local milestone, use mesh-llm's default native build path. On macOS
|
||||
this compiles patched llama.cpp/ggml with Metal support the first time a Sprout
|
||||
binary that depends on mesh is built. The result is cached under Cargo's git
|
||||
checkout of mesh-llm, so subsequent builds are much faster.
|
||||
|
||||
Prerequisites:
|
||||
|
||||
```bash
|
||||
xcode-select --install # if Command Line Tools are not installed yet
|
||||
brew install cmake # if cmake is not already available
|
||||
```
|
||||
|
||||
Then build normally:
|
||||
|
||||
```bash
|
||||
cargo build -p sprout-relay --bin sprout-relay
|
||||
cargo check --manifest-path desktop/src-tauri/Cargo.toml
|
||||
```
|
||||
|
||||
Expect the first build to take several minutes while mesh-llm prepares and builds
|
||||
patched llama.cpp. This is intentional for the local demo: there is no external
|
||||
binary artifact to fetch and no separate dylib path to configure.
|
||||
|
||||
## CI / release path
|
||||
|
||||
CI should not rebuild llama.cpp from scratch on every job. CI and release now
|
||||
prebuild the llama native libraries in a dedicated step (`prepare-llama.sh` +
|
||||
`build-llama.sh -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15`) and the Tauri build reuses
|
||||
them via `SKIPPY_LLAMA_AUTO_BUILD=0` + `LLAMA_STAGE_BUILD_DIR`. That build is
|
||||
cached with `actions/cache` keyed on the mesh-llm rev (resolved from
|
||||
`Cargo.lock`), so a cache hit skips the rebuild and a dependency bump
|
||||
invalidates the cache automatically — no workflow edit required.
|
||||
|
||||
A dynamic-link artifact path remains a possible future optimization. The
|
||||
mesh-llm build script supports dynamic linking with:
|
||||
|
||||
```bash
|
||||
export LLAMA_STAGE_LINK_MODE=dynamic
|
||||
export LLAMA_STAGE_LIB_DIR=/path/to/prebuilt/llama/libs
|
||||
```
|
||||
|
||||
Do not use dynamic-link locally unless you already have compatible `llama`,
|
||||
`llama-common`, and `mtmd` dynamic libraries. The default static build is the
|
||||
supported local path for M1.
|
||||
|
||||
## Current privacy limitation: public STUN
|
||||
|
||||
Sprout Desktop now refuses to start an embedded mesh node unless the active relay
|
||||
advertises a Sprout-owned `iroh_relay_url`, and it passes a fresh NIP-98 bearer
|
||||
to that relay. This prevents mesh-llm's empty-relay fallback to public iroh relay
|
||||
URLs.
|
||||
|
||||
mesh-llm `bd16da4` still performs raw public STUN on startup to discover the
|
||||
host's public IP (`stun.l.google.com`, `stun.cloudflare.com`, or
|
||||
`stun.stunprotocol.org`) and may include that public address in its invite token.
|
||||
That behavior is inside mesh-llm's host runtime and is not currently exposed as
|
||||
an SDK option. Treat it as a v1 limitation until mesh exposes a disable-public-
|
||||
STUN / relay-only-addressing knob.
|
||||
|
||||
## Mesh-compute e2e acceptance matrix
|
||||
|
||||
"Full e2e" for mesh-compute is necessarily layered: the Playwright harness
|
||||
drives the **web build** of the desktop UI through a bridge, so it cannot
|
||||
execute real Tauri mesh commands or real GGUF inference. We therefore split
|
||||
coverage into three layers and are explicit about what is real vs mocked.
|
||||
|
||||
| # | What it proves | Where | Real / Mocked | Runs in CI? | How to run |
|
||||
|---|----------------|-------|---------------|-------------|------------|
|
||||
| 1 | serve node + client node + mesh routing + **real inference** | `crates/sprout-relay/examples/mesh_serve_client_smoke.rs` | **REAL** (loads a model, runs inference, joins a real mesh) | No — hardware-gated | `just mesh-e2e-hardware` (or `cargo run -p sprout-relay --example mesh_serve_client_smoke`) |
|
||||
| 2 | admission **invariant**: relay membership is the only factor | `crates/sprout-relay/src/iroh_relay.rs` (`admission_from_membership` tests) | REAL policy logic, no I/O | **Yes** | `cargo test -p sprout-relay iroh_relay::tests::admission` |
|
||||
| 2b | live db-membership admission + member/non-member status reads | `crates/sprout-test-client/tests/e2e_mesh_llm.rs` (`trust_*`) | REAL relay over ws | No — env-gated (`MEMBER_NSEC`/`STRANGER_NSEC`, live relay) | see that file's module docs |
|
||||
| 3 | desktop UI contract: Share-compute start/stop, Run-on-relay-mesh preset, **ensure-before-spawn** order, membership-gated toggle | `desktop/tests/e2e/mesh-compute.spec.ts` | UI REAL, Tauri mesh commands MOCKED via the e2e bridge | **Yes** | `cd desktop && pnpm test:e2e:integration -- mesh-compute.spec.ts` |
|
||||
|
||||
`just mesh-e2e` runs the two CI-safe layers (2 + 3). Layer 1 is run on hardware.
|
||||
|
||||
### What "real" means per layer
|
||||
|
||||
- **Layer 1 is the only layer that proves inference.** It starts a real serve
|
||||
node on the GPU, a real client node that joins via the serve node's invite
|
||||
token, and asserts a chat completion *routed through the client* returns
|
||||
`finish_reason=stop` with non-empty content. Verified locally with
|
||||
SmolLM2-135M; point `MESH_SMOKE_MODEL` at a larger `.gguf` for scale.
|
||||
Note: even with mDNS discovery the join bootstraps through mesh's public iroh
|
||||
relay at the pinned rev — see the STUN limitation above.
|
||||
- **Layer 2 proves the auth invariant without faking it.** The policy mapping
|
||||
(`MembershipDecision` → admit/deny) is exercised directly: member → allow,
|
||||
open relay → allow, non-member → deny, owner-delegation → deny (v1),
|
||||
error → deny. A valid NIP-98 identity or possession of dial metadata is, by
|
||||
itself, never sufficient. The membership-only gate is what matters **when
|
||||
membership enforcement is enabled** (`require_relay_membership = true`); an
|
||||
open relay (`OpenRelay`) intentionally admits any valid NIP-98 signer, so
|
||||
`open relay → allow` is the deliberate disabled-enforcement case, not a
|
||||
contradiction of the membership gate.
|
||||
- **Layer 3 proves the UI contract, not inference.** The mesh Tauri commands
|
||||
are mocked, but the assertions are on real UI behavior and real command
|
||||
*ordering* (`mesh_ensure_client_node` is recorded before `create_managed_agent`),
|
||||
and on the membership gate (a non-member cannot enable relay-mesh at all).
|
||||
9
justfile
9
justfile
@@ -198,6 +198,15 @@ desktop-e2e-smoke:
|
||||
desktop-e2e-integration: _ensure-migrations
|
||||
cd {{desktop_dir}} && pnpm test:e2e:integration
|
||||
|
||||
# Mesh-compute e2e: the CI-safe layers (admission invariant + Playwright UI)
|
||||
mesh-e2e:
|
||||
cargo test -p sprout-relay iroh_relay::tests::admission
|
||||
cd {{desktop_dir}} && pnpm test:e2e:integration -- mesh-compute.spec.ts
|
||||
|
||||
# Mesh-compute Layer 1: REAL serve->client->inference on this machine (not CI)
|
||||
mesh-e2e-hardware:
|
||||
cargo run -p sprout-relay --example mesh_serve_client_smoke
|
||||
|
||||
# Run all checks suitable for CI / pre-push (no infra needed)
|
||||
ci: check test-unit desktop-test desktop-build desktop-tauri-check desktop-tauri-test web-build mobile-test
|
||||
|
||||
|
||||
Reference in New Issue
Block a user