huskies: merge 1208 story Ops/LLM sessions can reach gateway-mode + cross-project MCP (the biggest shell-fallback cause)
This commit is contained in:
@@ -354,6 +354,26 @@ impl GatewayState {
|
||||
self.sled_connections.write().await.remove(project_name);
|
||||
}
|
||||
|
||||
/// Look up the live sled connection for an explicit project name,
|
||||
/// returning a clone if one exists and has a recent heartbeat.
|
||||
///
|
||||
/// Returns `None` when no sled has connected for this project or when its
|
||||
/// heartbeat is overdue. Generalizes [`GatewayState::active_sled_connection`]
|
||||
/// to any registered project rather than only the active one (story 1208).
|
||||
pub async fn sled_connection_for(&self, project_name: &str) -> Option<SledConnection> {
|
||||
let conn = self
|
||||
.sled_connections
|
||||
.read()
|
||||
.await
|
||||
.get(project_name)
|
||||
.cloned()?;
|
||||
if conn.is_alive(HEARTBEAT_MAX_AGE_MS) {
|
||||
Some(conn)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up the live sled connection for the active project, returning a
|
||||
/// clone if one exists and has a recent heartbeat.
|
||||
///
|
||||
@@ -361,12 +381,7 @@ impl GatewayState {
|
||||
/// heartbeat is overdue.
|
||||
pub async fn active_sled_connection(&self) -> Option<SledConnection> {
|
||||
let name = self.active_project.read().await.clone();
|
||||
let conn = self.sled_connections.read().await.get(&name).cloned()?;
|
||||
if conn.is_alive(HEARTBEAT_MAX_AGE_MS) {
|
||||
Some(conn)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
self.sled_connection_for(&name).await
|
||||
}
|
||||
|
||||
/// Proxy an MCP request to the active project over its live sled-uplink
|
||||
@@ -389,6 +404,31 @@ impl GatewayState {
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Proxy an MCP request to an explicitly named project over its live
|
||||
/// sled-uplink WebSocket, without reading or mutating `active_project`
|
||||
/// (story 1208 AC 1).
|
||||
///
|
||||
/// Lets a single ops/LLM session address any registered project by name
|
||||
/// on a per-call basis instead of first mutating the shared
|
||||
/// `active_project` via `switch_project` — which avoids the race where a
|
||||
/// second concurrent caller's tool calls get proxied to the wrong
|
||||
/// project mid-session.
|
||||
///
|
||||
/// Returns the raw response body bytes ready to be relayed to the caller.
|
||||
pub async fn proxy_mcp_for_project(
|
||||
&self,
|
||||
project: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<Vec<u8>, String> {
|
||||
match self.sled_connection_for(project).await {
|
||||
Some(conn) => proxy_mcp_via_ws(&conn, bytes).await,
|
||||
None => Err(format!(
|
||||
"sled '{project}' has no live WS uplink connection; \
|
||||
ensure the sled is running and connected to this gateway"
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────────
|
||||
@@ -989,6 +1029,45 @@ mod tests {
|
||||
assert!(!state.sled_connections.read().await.contains_key("myproj"));
|
||||
}
|
||||
|
||||
// ── explicit per-project proxy targeting (story 1208) ──────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sled_connection_for_unregistered_project_returns_none() {
|
||||
let config = make_config(&[("alpha", "http://a:3001")]);
|
||||
let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
|
||||
assert!(state.sled_connection_for("nonexistent").await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sled_connection_for_finds_connection_by_explicit_name_not_active() {
|
||||
let config = make_config(&[("alpha", "http://a:3001"), ("beta", "http://b:3002")]);
|
||||
let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
|
||||
// "alpha" is active (first project), but the live connection is for "beta".
|
||||
assert_eq!(*state.active_project.read().await, "alpha");
|
||||
|
||||
let (tx, _rx) = mpsc::unbounded_channel();
|
||||
let conn = SledConnection {
|
||||
tx,
|
||||
last_heartbeat_ms: Arc::new(AtomicI64::new(chrono::Utc::now().timestamp_millis())),
|
||||
in_flight: Arc::new(TokioMutex::new(HashMap::new())),
|
||||
};
|
||||
state
|
||||
.register_sled_connection("beta".to_string(), conn)
|
||||
.await;
|
||||
|
||||
assert!(state.sled_connection_for("beta").await.is_some());
|
||||
assert!(state.active_sled_connection().await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_mcp_for_project_without_live_connection_fails() {
|
||||
let config = make_config(&[("alpha", "http://a:3001")]);
|
||||
let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap();
|
||||
let result = state.proxy_mcp_for_project("alpha", b"{}").await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("no live WS uplink"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_token_in_project_entry_populates_sled_tokens_map() {
|
||||
let mut projects = BTreeMap::new();
|
||||
|
||||
Reference in New Issue
Block a user