huskies: merge 899
This commit is contained in:
@@ -119,7 +119,7 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.map(|(name, entry)| (name.clone(), entry.url.clone()))
|
||||
.filter_map(|(name, entry)| entry.url.as_ref().map(|u| (name.clone(), u.clone())))
|
||||
.collect();
|
||||
let (bot_abort, bot_shutdown_tx) = gateway::io::spawn_gateway_bot(
|
||||
&config_dir,
|
||||
|
||||
+206
-29
@@ -7,12 +7,7 @@ use std::path::PathBuf;
|
||||
|
||||
fn make_test_state() -> Arc<GatewayState> {
|
||||
let mut projects = BTreeMap::new();
|
||||
projects.insert(
|
||||
"test".into(),
|
||||
ProjectEntry {
|
||||
url: "http://test:3001".into(),
|
||||
},
|
||||
);
|
||||
projects.insert("test".into(), ProjectEntry::with_url("http://test:3001"));
|
||||
let config = GatewayConfig {
|
||||
projects,
|
||||
sled_tokens: BTreeMap::new(),
|
||||
@@ -367,9 +362,7 @@ async fn init_project_registers_in_projects_toml_when_name_and_url_given() {
|
||||
let mut projects = BTreeMap::new();
|
||||
projects.insert(
|
||||
"existing".into(),
|
||||
ProjectEntry {
|
||||
url: "http://existing:3001".into(),
|
||||
},
|
||||
ProjectEntry::with_url("http://existing:3001"),
|
||||
);
|
||||
let config = GatewayConfig {
|
||||
projects,
|
||||
@@ -388,19 +381,17 @@ async fn init_project_registers_in_projects_toml_when_name_and_url_given() {
|
||||
|
||||
let projects = state.projects.read().await;
|
||||
assert!(projects.contains_key("new-project"));
|
||||
assert_eq!(projects["new-project"].url, "http://new-project:3002");
|
||||
assert_eq!(
|
||||
projects["new-project"].url.as_deref(),
|
||||
Some("http://new-project:3002")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn init_project_duplicate_name_returns_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut projects = BTreeMap::new();
|
||||
projects.insert(
|
||||
"taken".into(),
|
||||
ProjectEntry {
|
||||
url: "http://taken:3001".into(),
|
||||
},
|
||||
);
|
||||
projects.insert("taken".into(), ProjectEntry::with_url("http://taken:3001"));
|
||||
let config = GatewayConfig {
|
||||
projects,
|
||||
sled_tokens: BTreeMap::new(),
|
||||
@@ -452,7 +443,7 @@ async fn init_project_then_wizard_status_integration() {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
|
||||
let mut projects = BTreeMap::new();
|
||||
projects.insert("mock-project".into(), ProjectEntry { url: mock_url });
|
||||
projects.insert("mock-project".into(), ProjectEntry::with_url(mock_url));
|
||||
let config = GatewayConfig {
|
||||
projects,
|
||||
sled_tokens: BTreeMap::new(),
|
||||
@@ -972,12 +963,7 @@ async fn gateway_mcp_sse_proxy_streams_progress_and_final_response() {
|
||||
.await;
|
||||
|
||||
let mut projects = BTreeMap::new();
|
||||
projects.insert(
|
||||
"sled".to_string(),
|
||||
ProjectEntry {
|
||||
url: mock_sled.url(),
|
||||
},
|
||||
);
|
||||
projects.insert("sled".to_string(), ProjectEntry::with_url(mock_sled.url()));
|
||||
let config = GatewayConfig {
|
||||
projects,
|
||||
sled_tokens: BTreeMap::new(),
|
||||
@@ -1068,12 +1054,7 @@ async fn gateway_mcp_post_without_sse_returns_plain_json() {
|
||||
.await;
|
||||
|
||||
let mut projects = BTreeMap::new();
|
||||
projects.insert(
|
||||
"sled".to_string(),
|
||||
ProjectEntry {
|
||||
url: mock_sled.url(),
|
||||
},
|
||||
);
|
||||
projects.insert("sled".to_string(), ProjectEntry::with_url(mock_sled.url()));
|
||||
let config = GatewayConfig {
|
||||
projects,
|
||||
sled_tokens: BTreeMap::new(),
|
||||
@@ -1118,3 +1099,199 @@ async fn gateway_mcp_post_without_sse_returns_plain_json() {
|
||||
"Expected result in plain JSON response"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Story 899: MCP-over-WS uplink integration ────────────────────────────────
|
||||
|
||||
/// Build a `SledConnection` plus a spawned "mock sled" task that:
|
||||
///
|
||||
/// * Reads outbound `mcp_request` envelopes off the connection's channel.
|
||||
/// * Invokes the supplied closure to build a `result` value for each request.
|
||||
/// * Resolves the matching in-flight oneshot directly (the same effect the WS
|
||||
/// handler has when it receives an `mcp_response` from a real sled).
|
||||
///
|
||||
/// Returns the registered `SledConnection`.
|
||||
fn spawn_mock_sled<F>(handler: F) -> crate::service::gateway::SledConnection
|
||||
where
|
||||
F: Fn(&serde_json::Value) -> serde_json::Value + Send + Sync + 'static,
|
||||
{
|
||||
use crate::service::gateway::SledConnection;
|
||||
use std::sync::atomic::AtomicI64;
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let last_heartbeat_ms = Arc::new(AtomicI64::new(chrono::Utc::now().timestamp_millis()));
|
||||
let in_flight = Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
|
||||
|
||||
let conn = SledConnection {
|
||||
tx,
|
||||
last_heartbeat_ms,
|
||||
in_flight: Arc::clone(&in_flight),
|
||||
};
|
||||
let handler = Arc::new(handler);
|
||||
tokio::spawn(async move {
|
||||
while let Some(env) = rx.recv().await {
|
||||
if env.msg_type != "mcp_request" {
|
||||
continue;
|
||||
}
|
||||
let body_str = env
|
||||
.payload
|
||||
.get("body")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let body_json: serde_json::Value = match serde_json::from_str(&body_str) {
|
||||
Ok(v) => v,
|
||||
Err(_) => serde_json::Value::Null,
|
||||
};
|
||||
let result = handler(&body_json);
|
||||
let response = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": body_json.get("id").cloned().unwrap_or(serde_json::Value::Null),
|
||||
"result": result,
|
||||
});
|
||||
if let Some(oneshot_tx) = in_flight.lock().await.remove(&env.req_id) {
|
||||
let _ = oneshot_tx.send(response);
|
||||
}
|
||||
}
|
||||
});
|
||||
conn
|
||||
}
|
||||
|
||||
/// AC 9: gateway switches active project, calls tools/list and tools/call
|
||||
/// against a sled connected only via WS uplink, gets correct responses.
|
||||
#[tokio::test]
|
||||
async fn ws_only_sled_handles_tools_list_and_tools_call() {
|
||||
use crate::service::gateway::ProjectEntry;
|
||||
|
||||
// Project entry with NO url — WS-only.
|
||||
let mut projects = BTreeMap::new();
|
||||
projects.insert(
|
||||
"ws-only".into(),
|
||||
ProjectEntry {
|
||||
url: None,
|
||||
auth_token: Some("secret".into()),
|
||||
},
|
||||
);
|
||||
let config = GatewayConfig {
|
||||
projects,
|
||||
sled_tokens: BTreeMap::new(),
|
||||
};
|
||||
let state = Arc::new(GatewayState::new(config, PathBuf::new(), 3000).unwrap());
|
||||
|
||||
let conn = spawn_mock_sled(|body| {
|
||||
let method = body.get("method").and_then(|m| m.as_str()).unwrap_or("");
|
||||
match method {
|
||||
"tools/list" => serde_json::json!({
|
||||
"tools": [
|
||||
{ "name": "my_tool", "description": "test" }
|
||||
]
|
||||
}),
|
||||
"tools/call" => serde_json::json!({
|
||||
"content": [{ "type": "text", "text": "called ok" }]
|
||||
}),
|
||||
_ => serde_json::json!({ "echo": method }),
|
||||
}
|
||||
});
|
||||
state
|
||||
.register_sled_connection("ws-only".to_string(), conn)
|
||||
.await;
|
||||
|
||||
// tools/list via proxy_active_mcp.
|
||||
let body = serde_json::to_vec(&serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
}))
|
||||
.unwrap();
|
||||
let resp = state.proxy_active_mcp(&body).await.expect("ws proxy works");
|
||||
let resp_json: serde_json::Value = serde_json::from_slice(&resp).unwrap();
|
||||
assert_eq!(
|
||||
resp_json["result"]["tools"][0]["name"], "my_tool",
|
||||
"tools/list response must come from the WS-connected sled, not HTTP"
|
||||
);
|
||||
|
||||
// tools/call via proxy_active_mcp.
|
||||
let body = serde_json::to_vec(&serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/call",
|
||||
"params": { "name": "my_tool", "arguments": {} }
|
||||
}))
|
||||
.unwrap();
|
||||
let resp = state.proxy_active_mcp(&body).await.expect("ws proxy works");
|
||||
let resp_json: serde_json::Value = serde_json::from_slice(&resp).unwrap();
|
||||
assert_eq!(
|
||||
resp_json["result"]["content"][0]["text"], "called ok",
|
||||
"tools/call response must come from the WS-connected sled, not HTTP"
|
||||
);
|
||||
}
|
||||
|
||||
/// AC 10: two sleds connected to one gateway concurrently; gateway routes
|
||||
/// calls to the right sled based on active project.
|
||||
#[tokio::test]
|
||||
async fn two_concurrent_sleds_are_routed_by_active_project() {
|
||||
use crate::service::gateway::ProjectEntry;
|
||||
|
||||
let mut projects = BTreeMap::new();
|
||||
projects.insert(
|
||||
"alpha".into(),
|
||||
ProjectEntry {
|
||||
url: None,
|
||||
auth_token: Some("alpha-tok".into()),
|
||||
},
|
||||
);
|
||||
projects.insert(
|
||||
"beta".into(),
|
||||
ProjectEntry {
|
||||
url: None,
|
||||
auth_token: Some("beta-tok".into()),
|
||||
},
|
||||
);
|
||||
let config = GatewayConfig {
|
||||
projects,
|
||||
sled_tokens: BTreeMap::new(),
|
||||
};
|
||||
let state = Arc::new(GatewayState::new(config, PathBuf::new(), 3000).unwrap());
|
||||
|
||||
let alpha_conn = spawn_mock_sled(|_body| {
|
||||
serde_json::json!({
|
||||
"content": [{ "type": "text", "text": "from-alpha" }]
|
||||
})
|
||||
});
|
||||
let beta_conn = spawn_mock_sled(|_body| {
|
||||
serde_json::json!({
|
||||
"content": [{ "type": "text", "text": "from-beta" }]
|
||||
})
|
||||
});
|
||||
state
|
||||
.register_sled_connection("alpha".to_string(), alpha_conn)
|
||||
.await;
|
||||
state
|
||||
.register_sled_connection("beta".to_string(), beta_conn)
|
||||
.await;
|
||||
|
||||
// Switch to alpha.
|
||||
gateway::switch_project(&state, "alpha").await.unwrap();
|
||||
let body = serde_json::to_vec(&serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": { "name": "noop", "arguments": {} }
|
||||
}))
|
||||
.unwrap();
|
||||
let resp = state.proxy_active_mcp(&body).await.expect("ws proxy works");
|
||||
let resp_json: serde_json::Value = serde_json::from_slice(&resp).unwrap();
|
||||
assert_eq!(
|
||||
resp_json["result"]["content"][0]["text"], "from-alpha",
|
||||
"When active project is alpha, calls must route to the alpha sled"
|
||||
);
|
||||
|
||||
// Switch to beta.
|
||||
gateway::switch_project(&state, "beta").await.unwrap();
|
||||
let resp = state.proxy_active_mcp(&body).await.expect("ws proxy works");
|
||||
let resp_json: serde_json::Value = serde_json::from_slice(&resp).unwrap();
|
||||
assert_eq!(
|
||||
resp_json["result"]["content"][0]["text"], "from-beta",
|
||||
"When active project is beta, calls must route to the beta sled"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user