2026-04-13 13:02:41 +00:00
|
|
|
//! Multi-project gateway — proxies MCP calls to per-project Docker containers.
|
|
|
|
|
//!
|
|
|
|
|
//! When `huskies --gateway` is used, the server starts in gateway mode: it reads
|
|
|
|
|
//! a `projects.toml` config that maps project names to container URLs, maintains
|
|
|
|
|
//! an "active project" selection, and proxies all MCP tool calls to the active
|
|
|
|
|
//! project's container. Gateway-specific tools allow switching projects, querying
|
|
|
|
|
//! status, and aggregating health checks across all registered projects.
|
|
|
|
|
|
|
|
|
|
use poem::EndpointExt;
|
|
|
|
|
use poem::handler;
|
|
|
|
|
use poem::http::StatusCode;
|
2026-04-14 12:02:17 +00:00
|
|
|
use poem::web::Path as PoemPath;
|
2026-04-14 11:24:12 +00:00
|
|
|
use poem::web::{Data, Json};
|
2026-04-13 13:02:41 +00:00
|
|
|
use poem::{Body, Request, Response};
|
|
|
|
|
use reqwest::Client;
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
2026-04-13 14:07:08 +00:00
|
|
|
use serde_json::{Value, json};
|
2026-04-13 13:02:41 +00:00
|
|
|
use std::collections::BTreeMap;
|
2026-04-14 12:02:17 +00:00
|
|
|
use std::collections::HashMap;
|
2026-04-13 13:02:41 +00:00
|
|
|
use std::path::Path;
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
use tokio::sync::RwLock;
|
2026-04-14 12:02:17 +00:00
|
|
|
use uuid::Uuid;
|
2026-04-13 13:02:41 +00:00
|
|
|
|
2026-04-14 09:57:11 +00:00
|
|
|
// Re-export active_project type alias for clarity in gateway bot helpers.
|
|
|
|
|
type ActiveProject = Arc<RwLock<String>>;
|
|
|
|
|
|
2026-04-13 13:02:41 +00:00
|
|
|
// ── Config ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// A single project entry in `projects.toml`.
|
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
|
|
|
pub struct ProjectEntry {
|
|
|
|
|
/// Base URL of the project's huskies container (e.g. `http://localhost:3001`).
|
|
|
|
|
pub url: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Top-level `projects.toml` config.
|
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
|
|
|
pub struct GatewayConfig {
|
|
|
|
|
/// Map of project name → container URL.
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub projects: BTreeMap<String, ProjectEntry>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl GatewayConfig {
|
|
|
|
|
/// Load gateway config from a `projects.toml` file.
|
|
|
|
|
pub fn load(path: &Path) -> Result<Self, String> {
|
|
|
|
|
let contents = std::fs::read_to_string(path)
|
|
|
|
|
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
|
2026-04-13 14:07:08 +00:00
|
|
|
toml::from_str(&contents).map_err(|e| format!("invalid projects.toml: {e}"))
|
2026-04-13 13:02:41 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 12:02:17 +00:00
|
|
|
// ── Agent join types ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// A build agent that has registered with this gateway.
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct JoinedAgent {
|
|
|
|
|
/// Unique ID assigned by the gateway on registration.
|
|
|
|
|
pub id: String,
|
|
|
|
|
/// Human-readable label provided by the agent (e.g. `build-agent-abc123`).
|
|
|
|
|
pub label: String,
|
|
|
|
|
/// The agent's CRDT-sync WebSocket address (e.g. `ws://host:3001/crdt-sync`).
|
|
|
|
|
pub address: String,
|
|
|
|
|
/// Unix timestamp when the agent registered.
|
|
|
|
|
pub registered_at: f64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A one-time join token that has been generated but not yet consumed.
|
|
|
|
|
struct PendingToken {
|
|
|
|
|
#[allow(dead_code)]
|
|
|
|
|
created_at: f64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Request body sent by a build agent when registering with the gateway.
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
struct RegisterAgentRequest {
|
|
|
|
|
token: String,
|
|
|
|
|
label: String,
|
|
|
|
|
address: String,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 13:02:41 +00:00
|
|
|
// ── Gateway state ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// Shared gateway state threaded through HTTP handlers.
|
|
|
|
|
#[derive(Clone)]
|
|
|
|
|
pub struct GatewayState {
|
|
|
|
|
/// The parsed gateway config with all registered projects.
|
|
|
|
|
pub config: GatewayConfig,
|
|
|
|
|
/// The currently active project name.
|
|
|
|
|
pub active_project: Arc<RwLock<String>>,
|
|
|
|
|
/// HTTP client for proxying requests to project containers.
|
|
|
|
|
pub client: Client,
|
2026-04-14 12:02:17 +00:00
|
|
|
/// Build agents that have joined this gateway.
|
|
|
|
|
pub joined_agents: Arc<RwLock<Vec<JoinedAgent>>>,
|
|
|
|
|
/// One-time join tokens that have been issued but not yet consumed.
|
|
|
|
|
pending_tokens: Arc<RwLock<HashMap<String, PendingToken>>>,
|
2026-04-13 13:02:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl GatewayState {
|
|
|
|
|
/// Create a new gateway state from a config. The first project in the config
|
|
|
|
|
/// becomes the active project by default.
|
|
|
|
|
pub fn new(config: GatewayConfig) -> Result<Self, String> {
|
|
|
|
|
if config.projects.is_empty() {
|
|
|
|
|
return Err("projects.toml must define at least one project".to_string());
|
|
|
|
|
}
|
|
|
|
|
let first = config.projects.keys().next().unwrap().clone();
|
|
|
|
|
Ok(Self {
|
|
|
|
|
config,
|
|
|
|
|
active_project: Arc::new(RwLock::new(first)),
|
|
|
|
|
client: Client::new(),
|
2026-04-14 12:02:17 +00:00
|
|
|
joined_agents: Arc::new(RwLock::new(Vec::new())),
|
|
|
|
|
pending_tokens: Arc::new(RwLock::new(HashMap::new())),
|
2026-04-13 13:02:41 +00:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Get the URL of the currently active project.
|
|
|
|
|
async fn active_url(&self) -> Result<String, String> {
|
|
|
|
|
let name = self.active_project.read().await.clone();
|
|
|
|
|
self.config
|
|
|
|
|
.projects
|
|
|
|
|
.get(&name)
|
|
|
|
|
.map(|p| p.url.clone())
|
|
|
|
|
.ok_or_else(|| format!("active project '{name}' not found in config"))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── MCP proxy handler ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// JSON-RPC request (duplicated here to keep the gateway self-contained).
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
struct JsonRpcRequest {
|
|
|
|
|
jsonrpc: String,
|
|
|
|
|
id: Option<Value>,
|
|
|
|
|
method: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
params: Value,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// JSON-RPC response.
|
|
|
|
|
#[derive(Serialize)]
|
|
|
|
|
struct JsonRpcResponse {
|
|
|
|
|
jsonrpc: &'static str,
|
|
|
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
|
|
|
id: Option<Value>,
|
|
|
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
|
|
|
result: Option<Value>,
|
|
|
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
|
|
|
error: Option<JsonRpcError>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Serialize)]
|
|
|
|
|
struct JsonRpcError {
|
|
|
|
|
code: i64,
|
|
|
|
|
message: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl JsonRpcResponse {
|
|
|
|
|
fn success(id: Option<Value>, result: Value) -> Self {
|
2026-04-13 14:07:08 +00:00
|
|
|
Self {
|
|
|
|
|
jsonrpc: "2.0",
|
|
|
|
|
id,
|
|
|
|
|
result: Some(result),
|
|
|
|
|
error: None,
|
|
|
|
|
}
|
2026-04-13 13:02:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn error(id: Option<Value>, code: i64, message: String) -> Self {
|
2026-04-13 14:07:08 +00:00
|
|
|
Self {
|
|
|
|
|
jsonrpc: "2.0",
|
|
|
|
|
id,
|
|
|
|
|
result: None,
|
|
|
|
|
error: Some(JsonRpcError { code, message }),
|
|
|
|
|
}
|
2026-04-13 13:02:41 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn to_json_response(resp: JsonRpcResponse) -> Response {
|
|
|
|
|
let body = serde_json::to_vec(&resp).unwrap_or_default();
|
|
|
|
|
Response::builder()
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
.body(Body::from(body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Gateway-specific MCP tools exposed alongside the proxied tools.
|
|
|
|
|
const GATEWAY_TOOLS: &[&str] = &["switch_project", "gateway_status", "gateway_health"];
|
|
|
|
|
|
|
|
|
|
/// Main MCP POST handler for the gateway. Intercepts gateway-specific tools and
|
|
|
|
|
/// proxies everything else to the active project's container.
|
|
|
|
|
#[handler]
|
|
|
|
|
pub async fn gateway_mcp_post_handler(
|
|
|
|
|
req: &Request,
|
|
|
|
|
body: Body,
|
|
|
|
|
state: Data<&Arc<GatewayState>>,
|
|
|
|
|
) -> Response {
|
|
|
|
|
let content_type = req.header("content-type").unwrap_or("");
|
|
|
|
|
if !content_type.is_empty() && !content_type.contains("application/json") {
|
|
|
|
|
return to_json_response(JsonRpcResponse::error(
|
2026-04-13 14:07:08 +00:00
|
|
|
None,
|
|
|
|
|
-32700,
|
|
|
|
|
"Unsupported Content-Type; expected application/json".into(),
|
2026-04-13 13:02:41 +00:00
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let bytes = match body.into_bytes().await {
|
|
|
|
|
Ok(b) => b,
|
2026-04-13 14:07:08 +00:00
|
|
|
Err(_) => {
|
|
|
|
|
return to_json_response(JsonRpcResponse::error(None, -32700, "Parse error".into()));
|
|
|
|
|
}
|
2026-04-13 13:02:41 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let rpc: JsonRpcRequest = match serde_json::from_slice(&bytes) {
|
|
|
|
|
Ok(r) => r,
|
2026-04-13 14:07:08 +00:00
|
|
|
Err(_) => {
|
|
|
|
|
return to_json_response(JsonRpcResponse::error(None, -32700, "Parse error".into()));
|
|
|
|
|
}
|
2026-04-13 13:02:41 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if rpc.jsonrpc != "2.0" {
|
2026-04-13 14:07:08 +00:00
|
|
|
return to_json_response(JsonRpcResponse::error(
|
|
|
|
|
rpc.id,
|
|
|
|
|
-32600,
|
|
|
|
|
"Invalid JSON-RPC version".into(),
|
|
|
|
|
));
|
2026-04-13 13:02:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Accept notifications silently.
|
|
|
|
|
if rpc.id.is_none() || rpc.id.as_ref() == Some(&Value::Null) {
|
|
|
|
|
if rpc.method.starts_with("notifications/") {
|
|
|
|
|
return Response::builder()
|
|
|
|
|
.status(StatusCode::ACCEPTED)
|
|
|
|
|
.body(Body::empty());
|
|
|
|
|
}
|
|
|
|
|
return to_json_response(JsonRpcResponse::error(None, -32600, "Missing id".into()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
match rpc.method.as_str() {
|
|
|
|
|
"initialize" => to_json_response(handle_initialize(rpc.id)),
|
|
|
|
|
"tools/list" => {
|
|
|
|
|
// Merge gateway tools with proxied tools from the active project.
|
|
|
|
|
match merge_tools_list(&state, rpc.id.clone()).await {
|
|
|
|
|
Ok(resp) => to_json_response(resp),
|
|
|
|
|
Err(e) => to_json_response(JsonRpcResponse::error(rpc.id, -32603, e)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
"tools/call" => {
|
2026-04-13 14:07:08 +00:00
|
|
|
let tool_name = rpc
|
|
|
|
|
.params
|
2026-04-13 13:02:41 +00:00
|
|
|
.get("name")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.unwrap_or("");
|
|
|
|
|
|
|
|
|
|
if GATEWAY_TOOLS.contains(&tool_name) {
|
|
|
|
|
to_json_response(handle_gateway_tool(tool_name, &rpc.params, &state).await)
|
|
|
|
|
} else {
|
|
|
|
|
// Proxy to active project's container.
|
|
|
|
|
match proxy_mcp_call(&state, &bytes).await {
|
|
|
|
|
Ok(resp_body) => Response::builder()
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
.body(Body::from(resp_body)),
|
|
|
|
|
Err(e) => to_json_response(JsonRpcResponse::error(
|
2026-04-13 14:07:08 +00:00
|
|
|
rpc.id,
|
|
|
|
|
-32603,
|
|
|
|
|
format!("proxy error: {e}"),
|
2026-04-13 13:02:41 +00:00
|
|
|
)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => {
|
|
|
|
|
// Proxy unknown methods too.
|
|
|
|
|
match proxy_mcp_call(&state, &bytes).await {
|
|
|
|
|
Ok(resp_body) => Response::builder()
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
.body(Body::from(resp_body)),
|
|
|
|
|
Err(e) => to_json_response(JsonRpcResponse::error(
|
2026-04-13 14:07:08 +00:00
|
|
|
rpc.id,
|
|
|
|
|
-32603,
|
|
|
|
|
format!("proxy error: {e}"),
|
2026-04-13 13:02:41 +00:00
|
|
|
)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// GET handler — method not allowed (matches the regular MCP endpoint behavior).
|
|
|
|
|
#[handler]
|
|
|
|
|
pub async fn gateway_mcp_get_handler() -> Response {
|
|
|
|
|
Response::builder()
|
|
|
|
|
.status(StatusCode::METHOD_NOT_ALLOWED)
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Protocol handlers ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
fn handle_initialize(id: Option<Value>) -> JsonRpcResponse {
|
|
|
|
|
JsonRpcResponse::success(
|
|
|
|
|
id,
|
|
|
|
|
json!({
|
|
|
|
|
"protocolVersion": "2025-03-26",
|
|
|
|
|
"capabilities": { "tools": {} },
|
|
|
|
|
"serverInfo": {
|
|
|
|
|
"name": "huskies-gateway",
|
|
|
|
|
"version": "1.0.0"
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Gateway tool definitions.
|
|
|
|
|
fn gateway_tool_definitions() -> Vec<Value> {
|
|
|
|
|
vec![
|
|
|
|
|
json!({
|
|
|
|
|
"name": "switch_project",
|
|
|
|
|
"description": "Switch the active project. All subsequent MCP tool calls will be proxied to this project's container.",
|
|
|
|
|
"inputSchema": {
|
|
|
|
|
"type": "object",
|
|
|
|
|
"properties": {
|
|
|
|
|
"project": {
|
|
|
|
|
"type": "string",
|
|
|
|
|
"description": "Name of the project to switch to (must exist in projects.toml)"
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
"required": ["project"]
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
json!({
|
|
|
|
|
"name": "gateway_status",
|
|
|
|
|
"description": "Show pipeline status for the active project by proxying the get_pipeline_status tool call.",
|
|
|
|
|
"inputSchema": {
|
|
|
|
|
"type": "object",
|
|
|
|
|
"properties": {}
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
json!({
|
|
|
|
|
"name": "gateway_health",
|
|
|
|
|
"description": "Health check aggregation across all registered projects. Returns the health status of every project container.",
|
|
|
|
|
"inputSchema": {
|
|
|
|
|
"type": "object",
|
|
|
|
|
"properties": {}
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Fetch tools/list from the active project and merge in gateway tools.
|
|
|
|
|
async fn merge_tools_list(
|
|
|
|
|
state: &GatewayState,
|
|
|
|
|
id: Option<Value>,
|
|
|
|
|
) -> Result<JsonRpcResponse, String> {
|
|
|
|
|
let url = state.active_url().await?;
|
|
|
|
|
let mcp_url = format!("{}/mcp", url.trim_end_matches('/'));
|
|
|
|
|
|
|
|
|
|
let rpc_body = json!({
|
|
|
|
|
"jsonrpc": "2.0",
|
|
|
|
|
"id": 1,
|
|
|
|
|
"method": "tools/list",
|
|
|
|
|
"params": {}
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-13 14:07:08 +00:00
|
|
|
let resp = state
|
|
|
|
|
.client
|
2026-04-13 13:02:41 +00:00
|
|
|
.post(&mcp_url)
|
|
|
|
|
.json(&rpc_body)
|
|
|
|
|
.send()
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| format!("failed to reach {mcp_url}: {e}"))?;
|
|
|
|
|
|
2026-04-13 14:07:08 +00:00
|
|
|
let resp_json: Value = resp
|
|
|
|
|
.json()
|
|
|
|
|
.await
|
2026-04-13 13:02:41 +00:00
|
|
|
.map_err(|e| format!("invalid JSON from upstream: {e}"))?;
|
|
|
|
|
|
|
|
|
|
let mut tools: Vec<Value> = resp_json
|
|
|
|
|
.get("result")
|
|
|
|
|
.and_then(|r| r.get("tools"))
|
|
|
|
|
.and_then(|t| t.as_array())
|
|
|
|
|
.cloned()
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
|
|
|
|
|
// Prepend gateway-specific tools.
|
|
|
|
|
let mut all_tools = gateway_tool_definitions();
|
|
|
|
|
all_tools.append(&mut tools);
|
|
|
|
|
|
|
|
|
|
Ok(JsonRpcResponse::success(id, json!({ "tools": all_tools })))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Proxy a raw MCP request body to the active project's container.
|
2026-04-13 14:07:08 +00:00
|
|
|
async fn proxy_mcp_call(state: &GatewayState, request_bytes: &[u8]) -> Result<Vec<u8>, String> {
|
2026-04-13 13:02:41 +00:00
|
|
|
let url = state.active_url().await?;
|
|
|
|
|
let mcp_url = format!("{}/mcp", url.trim_end_matches('/'));
|
|
|
|
|
|
2026-04-13 14:07:08 +00:00
|
|
|
let resp = state
|
|
|
|
|
.client
|
2026-04-13 13:02:41 +00:00
|
|
|
.post(&mcp_url)
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
.body(request_bytes.to_vec())
|
|
|
|
|
.send()
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| format!("failed to reach {mcp_url}: {e}"))?;
|
|
|
|
|
|
|
|
|
|
resp.bytes()
|
|
|
|
|
.await
|
|
|
|
|
.map(|b| b.to_vec())
|
|
|
|
|
.map_err(|e| format!("failed to read response from {mcp_url}: {e}"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Gateway-specific tools ───────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// Dispatch a gateway-specific tool call.
|
|
|
|
|
async fn handle_gateway_tool(
|
|
|
|
|
tool_name: &str,
|
|
|
|
|
params: &Value,
|
|
|
|
|
state: &GatewayState,
|
|
|
|
|
) -> JsonRpcResponse {
|
|
|
|
|
let id = None; // The caller wraps this in a proper response.
|
|
|
|
|
match tool_name {
|
|
|
|
|
"switch_project" => handle_switch_project(params, state).await,
|
|
|
|
|
"gateway_status" => handle_gateway_status(state).await,
|
|
|
|
|
"gateway_health" => handle_gateway_health(state).await,
|
|
|
|
|
_ => JsonRpcResponse::error(id, -32601, format!("Unknown gateway tool: {tool_name}")),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Switch the active project.
|
|
|
|
|
async fn handle_switch_project(params: &Value, state: &GatewayState) -> JsonRpcResponse {
|
|
|
|
|
let project = params
|
|
|
|
|
.get("arguments")
|
|
|
|
|
.and_then(|a| a.get("project"))
|
|
|
|
|
.or_else(|| params.get("project"))
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.unwrap_or("");
|
|
|
|
|
|
|
|
|
|
if project.is_empty() {
|
|
|
|
|
return JsonRpcResponse::error(None, -32602, "missing required parameter: project".into());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !state.config.projects.contains_key(project) {
|
|
|
|
|
let available: Vec<&str> = state.config.projects.keys().map(|s| s.as_str()).collect();
|
|
|
|
|
return JsonRpcResponse::error(
|
2026-04-13 14:07:08 +00:00
|
|
|
None,
|
|
|
|
|
-32602,
|
|
|
|
|
format!(
|
|
|
|
|
"unknown project '{project}'. Available: {}",
|
|
|
|
|
available.join(", ")
|
|
|
|
|
),
|
2026-04-13 13:02:41 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
*state.active_project.write().await = project.to_string();
|
|
|
|
|
|
|
|
|
|
let url = &state.config.projects[project].url;
|
|
|
|
|
JsonRpcResponse::success(
|
|
|
|
|
None,
|
|
|
|
|
json!({
|
|
|
|
|
"content": [{
|
|
|
|
|
"type": "text",
|
|
|
|
|
"text": format!("Switched to project '{project}' ({})", url)
|
|
|
|
|
}]
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Show pipeline status for the active project by proxying `get_pipeline_status`.
|
|
|
|
|
async fn handle_gateway_status(state: &GatewayState) -> JsonRpcResponse {
|
|
|
|
|
let active = state.active_project.read().await.clone();
|
|
|
|
|
let url = match state.active_url().await {
|
|
|
|
|
Ok(u) => u,
|
|
|
|
|
Err(e) => return JsonRpcResponse::error(None, -32603, e),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mcp_url = format!("{}/mcp", url.trim_end_matches('/'));
|
|
|
|
|
let rpc_body = json!({
|
|
|
|
|
"jsonrpc": "2.0",
|
|
|
|
|
"id": 1,
|
|
|
|
|
"method": "tools/call",
|
|
|
|
|
"params": {
|
|
|
|
|
"name": "get_pipeline_status",
|
|
|
|
|
"arguments": {}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
match state.client.post(&mcp_url).json(&rpc_body).send().await {
|
|
|
|
|
Ok(resp) => {
|
|
|
|
|
match resp.json::<Value>().await {
|
|
|
|
|
Ok(upstream) => {
|
|
|
|
|
// Extract the result from the upstream response and wrap it.
|
|
|
|
|
let pipeline = upstream.get("result").cloned().unwrap_or(json!(null));
|
|
|
|
|
JsonRpcResponse::success(
|
|
|
|
|
None,
|
|
|
|
|
json!({
|
|
|
|
|
"content": [{
|
|
|
|
|
"type": "text",
|
|
|
|
|
"text": format!(
|
|
|
|
|
"Pipeline status for '{active}':\n{}",
|
|
|
|
|
serde_json::to_string_pretty(&pipeline).unwrap_or_default()
|
|
|
|
|
)
|
|
|
|
|
}]
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-04-13 14:07:08 +00:00
|
|
|
Err(e) => {
|
|
|
|
|
JsonRpcResponse::error(None, -32603, format!("invalid upstream response: {e}"))
|
|
|
|
|
}
|
2026-04-13 13:02:41 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(e) => JsonRpcResponse::error(None, -32603, format!("failed to reach {mcp_url}: {e}")),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Aggregate health checks across all registered projects.
|
|
|
|
|
async fn handle_gateway_health(state: &GatewayState) -> JsonRpcResponse {
|
|
|
|
|
let mut results = BTreeMap::new();
|
|
|
|
|
|
|
|
|
|
for (name, entry) in &state.config.projects {
|
|
|
|
|
let health_url = format!("{}/health", entry.url.trim_end_matches('/'));
|
|
|
|
|
let status = match state.client.get(&health_url).send().await {
|
|
|
|
|
Ok(resp) => {
|
|
|
|
|
if resp.status().is_success() {
|
|
|
|
|
"healthy".to_string()
|
|
|
|
|
} else {
|
|
|
|
|
format!("unhealthy (HTTP {})", resp.status().as_u16())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(e) => format!("unreachable: {e}"),
|
|
|
|
|
};
|
|
|
|
|
results.insert(name.clone(), status);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let active = state.active_project.read().await.clone();
|
|
|
|
|
JsonRpcResponse::success(
|
|
|
|
|
None,
|
|
|
|
|
json!({
|
|
|
|
|
"content": [{
|
|
|
|
|
"type": "text",
|
|
|
|
|
"text": format!(
|
|
|
|
|
"Health check (active: '{active}'):\n{}",
|
|
|
|
|
results.iter()
|
|
|
|
|
.map(|(name, status)| format!(" {name}: {status}"))
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join("\n")
|
|
|
|
|
)
|
|
|
|
|
}]
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 12:02:17 +00:00
|
|
|
// ── Agent join handlers ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// `GET /gateway/mode` — returns `{"mode":"gateway"}` so clients can detect gateway mode.
|
|
|
|
|
#[handler]
|
|
|
|
|
pub async fn gateway_mode_handler() -> Response {
|
|
|
|
|
let body = json!({ "mode": "gateway" });
|
|
|
|
|
Response::builder()
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&body).unwrap_or_default()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `POST /gateway/tokens` — generate a one-time join token for a build agent.
|
|
|
|
|
///
|
|
|
|
|
/// Returns `{"token": "<uuid>"}`. The token is valid until consumed by
|
|
|
|
|
/// `POST /gateway/register` or the process restarts.
|
|
|
|
|
#[handler]
|
|
|
|
|
pub async fn gateway_generate_token_handler(state: Data<&Arc<GatewayState>>) -> Response {
|
|
|
|
|
let token = Uuid::new_v4().to_string();
|
|
|
|
|
let now = chrono::Utc::now().timestamp() as f64;
|
|
|
|
|
state
|
|
|
|
|
.pending_tokens
|
|
|
|
|
.write()
|
|
|
|
|
.await
|
|
|
|
|
.insert(token.clone(), PendingToken { created_at: now });
|
|
|
|
|
crate::slog!("[gateway] Generated join token {:.8}…", &token);
|
|
|
|
|
let body = json!({ "token": token });
|
|
|
|
|
Response::builder()
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&body).unwrap_or_default()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `POST /gateway/register` — build agent presents its join token and registers.
|
|
|
|
|
///
|
|
|
|
|
/// Expects JSON body: `{ "token": "...", "label": "...", "address": "..." }`.
|
|
|
|
|
/// On success returns the `JoinedAgent` record. The token is consumed immediately.
|
|
|
|
|
#[handler]
|
|
|
|
|
pub async fn gateway_register_agent_handler(
|
|
|
|
|
body: Body,
|
|
|
|
|
state: Data<&Arc<GatewayState>>,
|
|
|
|
|
) -> Response {
|
|
|
|
|
let bytes = match body.into_bytes().await {
|
|
|
|
|
Ok(b) => b,
|
|
|
|
|
Err(_) => {
|
|
|
|
|
return Response::builder()
|
|
|
|
|
.status(StatusCode::BAD_REQUEST)
|
|
|
|
|
.body(Body::from("could not read request body"));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let req: RegisterAgentRequest = match serde_json::from_slice(&bytes) {
|
|
|
|
|
Ok(r) => r,
|
|
|
|
|
Err(_) => {
|
|
|
|
|
return Response::builder()
|
|
|
|
|
.status(StatusCode::BAD_REQUEST)
|
|
|
|
|
.body(Body::from("invalid JSON body"));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Validate and consume the token.
|
|
|
|
|
let mut tokens = state.pending_tokens.write().await;
|
|
|
|
|
if !tokens.contains_key(&req.token) {
|
|
|
|
|
return Response::builder()
|
|
|
|
|
.status(StatusCode::UNAUTHORIZED)
|
|
|
|
|
.body(Body::from("invalid or already-used join token"));
|
|
|
|
|
}
|
|
|
|
|
tokens.remove(&req.token);
|
|
|
|
|
drop(tokens);
|
|
|
|
|
|
|
|
|
|
let agent = JoinedAgent {
|
|
|
|
|
id: Uuid::new_v4().to_string(),
|
|
|
|
|
label: req.label,
|
|
|
|
|
address: req.address,
|
|
|
|
|
registered_at: chrono::Utc::now().timestamp() as f64,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
crate::slog!(
|
|
|
|
|
"[gateway] Agent '{}' registered (id={})",
|
|
|
|
|
agent.label,
|
|
|
|
|
agent.id
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
state.joined_agents.write().await.push(agent.clone());
|
|
|
|
|
|
|
|
|
|
let body = serde_json::to_vec(&agent).unwrap_or_default();
|
|
|
|
|
Response::builder()
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
.body(Body::from(body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `GET /gateway/agents` — list all registered build agents.
|
|
|
|
|
#[handler]
|
|
|
|
|
pub async fn gateway_list_agents_handler(state: Data<&Arc<GatewayState>>) -> Response {
|
|
|
|
|
let agents = state.joined_agents.read().await.clone();
|
|
|
|
|
let body = serde_json::to_vec(&agents).unwrap_or_default();
|
|
|
|
|
Response::builder()
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
.body(Body::from(body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `DELETE /gateway/agents/:id` — remove a registered build agent.
|
|
|
|
|
#[handler]
|
|
|
|
|
pub async fn gateway_remove_agent_handler(
|
|
|
|
|
PoemPath(id): PoemPath<String>,
|
|
|
|
|
state: Data<&Arc<GatewayState>>,
|
|
|
|
|
) -> Response {
|
|
|
|
|
let mut agents = state.joined_agents.write().await;
|
|
|
|
|
let before = agents.len();
|
|
|
|
|
agents.retain(|a| a.id != id);
|
|
|
|
|
let removed = agents.len() < before;
|
|
|
|
|
drop(agents);
|
|
|
|
|
|
|
|
|
|
if removed {
|
|
|
|
|
crate::slog!("[gateway] Removed agent id={id}");
|
|
|
|
|
Response::builder()
|
|
|
|
|
.status(StatusCode::NO_CONTENT)
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
} else {
|
|
|
|
|
Response::builder()
|
|
|
|
|
.status(StatusCode::NOT_FOUND)
|
|
|
|
|
.body(Body::from("agent not found"))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 13:02:41 +00:00
|
|
|
// ── Health aggregation endpoint ──────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// HTTP GET `/health` handler for the gateway — aggregates health from all projects.
|
|
|
|
|
#[handler]
|
|
|
|
|
pub async fn gateway_health_handler(state: Data<&Arc<GatewayState>>) -> Response {
|
|
|
|
|
let mut all_healthy = true;
|
|
|
|
|
let mut statuses = BTreeMap::new();
|
|
|
|
|
|
|
|
|
|
for (name, entry) in &state.config.projects {
|
|
|
|
|
let health_url = format!("{}/health", entry.url.trim_end_matches('/'));
|
|
|
|
|
let healthy = match state.client.get(&health_url).send().await {
|
|
|
|
|
Ok(resp) => resp.status().is_success(),
|
|
|
|
|
Err(_) => false,
|
|
|
|
|
};
|
|
|
|
|
if !healthy {
|
|
|
|
|
all_healthy = false;
|
|
|
|
|
}
|
|
|
|
|
statuses.insert(name.clone(), if healthy { "ok" } else { "error" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let body = json!({
|
|
|
|
|
"status": if all_healthy { "ok" } else { "degraded" },
|
|
|
|
|
"projects": statuses,
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-13 14:07:08 +00:00
|
|
|
let status = if all_healthy {
|
|
|
|
|
StatusCode::OK
|
|
|
|
|
} else {
|
|
|
|
|
StatusCode::SERVICE_UNAVAILABLE
|
|
|
|
|
};
|
2026-04-13 13:02:41 +00:00
|
|
|
Response::builder()
|
|
|
|
|
.status(status)
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&body).unwrap_or_default()))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 11:24:12 +00:00
|
|
|
// ── Gateway Web UI ───────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// Self-contained HTML page for the gateway web UI. Fetches project list from
|
|
|
|
|
/// `/api/gateway` and switches projects via `POST /api/gateway/switch`, which
|
|
|
|
|
/// internally calls the `switch_project` MCP tool logic.
|
|
|
|
|
const GATEWAY_UI_HTML: &str = r#"<!DOCTYPE html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
<title>Huskies Gateway</title>
|
|
|
|
|
<style>
|
|
|
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
|
body {
|
|
|
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
|
|
|
background: #0f172a;
|
|
|
|
|
color: #e2e8f0;
|
|
|
|
|
min-height: 100vh;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
}
|
|
|
|
|
.card {
|
|
|
|
|
background: #1e293b;
|
|
|
|
|
border: 1px solid #334155;
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
padding: 2rem;
|
|
|
|
|
width: 100%;
|
|
|
|
|
max-width: 480px;
|
|
|
|
|
box-shadow: 0 4px 24px rgba(0,0,0,0.4);
|
|
|
|
|
}
|
|
|
|
|
.header {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
margin-bottom: 1.5rem;
|
|
|
|
|
}
|
|
|
|
|
.logo { font-size: 1.75rem; }
|
|
|
|
|
h1 { font-size: 1.25rem; font-weight: 600; color: #f8fafc; }
|
|
|
|
|
.subtitle { font-size: 0.8rem; color: #64748b; margin-top: 0.125rem; }
|
|
|
|
|
label {
|
|
|
|
|
display: block;
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
color: #94a3b8;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
letter-spacing: 0.05em;
|
|
|
|
|
margin-bottom: 0.5rem;
|
|
|
|
|
}
|
|
|
|
|
select {
|
|
|
|
|
width: 100%;
|
|
|
|
|
padding: 0.625rem 0.875rem;
|
|
|
|
|
background: #0f172a;
|
|
|
|
|
border: 1px solid #334155;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
color: #f1f5f9;
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
appearance: none;
|
|
|
|
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2394a3b8' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
|
|
|
|
background-repeat: no-repeat;
|
|
|
|
|
background-position: right 0.875rem center;
|
|
|
|
|
padding-right: 2.5rem;
|
|
|
|
|
}
|
|
|
|
|
select:focus { outline: none; border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99,102,241,0.25); }
|
|
|
|
|
.active-badge {
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 0.375rem;
|
|
|
|
|
background: rgba(99,102,241,0.15);
|
|
|
|
|
border: 1px solid rgba(99,102,241,0.4);
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
padding: 0.25rem 0.75rem;
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
color: #a5b4fc;
|
|
|
|
|
margin-top: 0.875rem;
|
|
|
|
|
}
|
|
|
|
|
.dot { width: 6px; height: 6px; border-radius: 50%; background: #6366f1; }
|
|
|
|
|
.status { margin-top: 1rem; font-size: 0.8rem; color: #64748b; min-height: 1.25rem; }
|
|
|
|
|
.status.ok { color: #4ade80; }
|
|
|
|
|
.status.err { color: #f87171; }
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<div class="card">
|
|
|
|
|
<div class="header">
|
|
|
|
|
<span class="logo">🐺</span>
|
|
|
|
|
<div>
|
|
|
|
|
<h1>Huskies Gateway</h1>
|
|
|
|
|
<div class="subtitle">Multi-project orchestration</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<label for="project-select">Active Project</label>
|
|
|
|
|
<select id="project-select" onchange="switchProject(this.value)">
|
|
|
|
|
<option disabled>Loading…</option>
|
|
|
|
|
</select>
|
|
|
|
|
<div id="active-label" class="active-badge" style="display:none">
|
|
|
|
|
<span class="dot"></span>
|
|
|
|
|
<span id="active-name"></span>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="status" class="status"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<script>
|
|
|
|
|
async function loadState() {
|
|
|
|
|
try {
|
|
|
|
|
const r = await fetch('/api/gateway');
|
|
|
|
|
const data = await r.json();
|
|
|
|
|
const sel = document.getElementById('project-select');
|
|
|
|
|
sel.innerHTML = '';
|
|
|
|
|
for (const p of data.projects) {
|
|
|
|
|
const opt = document.createElement('option');
|
|
|
|
|
opt.value = p.name;
|
|
|
|
|
opt.textContent = p.name + ' — ' + p.url;
|
|
|
|
|
if (p.name === data.active) opt.selected = true;
|
|
|
|
|
sel.appendChild(opt);
|
|
|
|
|
}
|
|
|
|
|
document.getElementById('active-name').textContent = data.active;
|
|
|
|
|
document.getElementById('active-label').style.display = 'inline-flex';
|
|
|
|
|
} catch(e) {
|
|
|
|
|
document.getElementById('status').textContent = 'Failed to load state: ' + e;
|
|
|
|
|
document.getElementById('status').className = 'status err';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
async function switchProject(name) {
|
|
|
|
|
const statusEl = document.getElementById('status');
|
|
|
|
|
statusEl.className = 'status';
|
|
|
|
|
statusEl.textContent = 'Switching…';
|
|
|
|
|
try {
|
|
|
|
|
const r = await fetch('/api/gateway/switch', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {'Content-Type': 'application/json'},
|
|
|
|
|
body: JSON.stringify({project: name})
|
|
|
|
|
});
|
|
|
|
|
const data = await r.json();
|
|
|
|
|
if (data.ok) {
|
|
|
|
|
document.getElementById('active-name').textContent = name;
|
|
|
|
|
statusEl.className = 'status ok';
|
|
|
|
|
statusEl.textContent = 'Switched to ' + name;
|
|
|
|
|
} else {
|
|
|
|
|
statusEl.className = 'status err';
|
|
|
|
|
statusEl.textContent = data.error || 'Switch failed';
|
|
|
|
|
loadState();
|
|
|
|
|
}
|
|
|
|
|
} catch(e) {
|
|
|
|
|
statusEl.className = 'status err';
|
|
|
|
|
statusEl.textContent = 'Error: ' + e;
|
|
|
|
|
loadState();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
loadState();
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
"#;
|
|
|
|
|
|
|
|
|
|
/// Serve the gateway web UI HTML page at `GET /`.
|
|
|
|
|
#[handler]
|
|
|
|
|
pub async fn gateway_index_handler() -> Response {
|
|
|
|
|
Response::builder()
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
.header("Content-Type", "text/html; charset=utf-8")
|
|
|
|
|
.body(Body::from(GATEWAY_UI_HTML))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `GET /api/gateway` — returns the list of registered projects and the active project.
|
|
|
|
|
#[handler]
|
|
|
|
|
pub async fn gateway_api_handler(state: Data<&Arc<GatewayState>>) -> Response {
|
|
|
|
|
let active = state.active_project.read().await.clone();
|
|
|
|
|
let projects: Vec<Value> = state
|
|
|
|
|
.config
|
|
|
|
|
.projects
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|(name, entry)| {
|
|
|
|
|
json!({
|
|
|
|
|
"name": name,
|
|
|
|
|
"url": entry.url,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
let body = json!({ "active": active, "projects": projects });
|
|
|
|
|
Response::builder()
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&body).unwrap_or_default()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Request body for `POST /api/gateway/switch`.
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
struct SwitchRequest {
|
|
|
|
|
project: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `POST /api/gateway/switch` — switch the active project by calling the
|
|
|
|
|
/// `switch_project` MCP tool logic, then return `{"ok": true}` or `{"ok": false, "error": "..."}`.
|
|
|
|
|
#[handler]
|
|
|
|
|
pub async fn gateway_switch_handler(
|
|
|
|
|
state: Data<&Arc<GatewayState>>,
|
|
|
|
|
body: Json<SwitchRequest>,
|
|
|
|
|
) -> Response {
|
|
|
|
|
let params = json!({ "arguments": { "project": body.project } });
|
|
|
|
|
let resp = handle_switch_project(¶ms, &state).await;
|
|
|
|
|
|
|
|
|
|
let (ok, error) = if resp.result.is_some() {
|
|
|
|
|
(true, None)
|
|
|
|
|
} else {
|
|
|
|
|
let msg = resp
|
|
|
|
|
.error
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|e| e.message.clone())
|
|
|
|
|
.unwrap_or_else(|| "unknown error".to_string());
|
|
|
|
|
(false, Some(msg))
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let body_val = if ok {
|
|
|
|
|
json!({ "ok": true })
|
|
|
|
|
} else {
|
|
|
|
|
json!({ "ok": false, "error": error })
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let status = if ok {
|
|
|
|
|
StatusCode::OK
|
|
|
|
|
} else {
|
|
|
|
|
StatusCode::BAD_REQUEST
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Response::builder()
|
|
|
|
|
.status(status)
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
serde_json::to_vec(&body_val).unwrap_or_default(),
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 13:02:41 +00:00
|
|
|
// ── Gateway server startup ───────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// Start the gateway HTTP server. This is the entry point when `--gateway` is used.
|
|
|
|
|
pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
|
|
|
|
|
let config = GatewayConfig::load(config_path).map_err(std::io::Error::other)?;
|
|
|
|
|
let state = GatewayState::new(config).map_err(std::io::Error::other)?;
|
|
|
|
|
let state_arc = Arc::new(state);
|
|
|
|
|
|
|
|
|
|
let active = state_arc.active_project.read().await.clone();
|
|
|
|
|
crate::slog!("[gateway] Starting gateway on port {port}, active project: {active}");
|
|
|
|
|
crate::slog!(
|
|
|
|
|
"[gateway] Registered projects: {}",
|
2026-04-13 14:07:08 +00:00
|
|
|
state_arc
|
|
|
|
|
.config
|
|
|
|
|
.projects
|
|
|
|
|
.keys()
|
|
|
|
|
.cloned()
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join(", ")
|
2026-04-13 13:02:41 +00:00
|
|
|
);
|
|
|
|
|
|
2026-04-14 09:57:11 +00:00
|
|
|
// Locate the gateway config directory (parent of `projects.toml`).
|
|
|
|
|
let config_dir = config_path
|
|
|
|
|
.parent()
|
|
|
|
|
.unwrap_or(std::path::Path::new("."))
|
|
|
|
|
.to_path_buf();
|
|
|
|
|
|
|
|
|
|
// Write `.mcp.json` so that the gateway's Matrix bot's Claude Code CLI
|
|
|
|
|
// connects to this gateway's MCP endpoint (which proxies to the active project).
|
|
|
|
|
if let Err(e) = write_gateway_mcp_json(&config_dir, port) {
|
|
|
|
|
crate::slog!("[gateway] Warning: could not write .mcp.json: {e}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Spawn the Matrix bot if `.huskies/bot.toml` exists in the config directory.
|
|
|
|
|
let gateway_projects: Vec<String> = state_arc.config.projects.keys().cloned().collect();
|
|
|
|
|
spawn_gateway_bot(
|
|
|
|
|
&config_dir,
|
|
|
|
|
Arc::clone(&state_arc.active_project),
|
|
|
|
|
gateway_projects,
|
|
|
|
|
port,
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-13 13:02:41 +00:00
|
|
|
let route = poem::Route::new()
|
2026-04-14 11:24:12 +00:00
|
|
|
.at("/", poem::get(gateway_index_handler))
|
|
|
|
|
.at("/api/gateway", poem::get(gateway_api_handler))
|
|
|
|
|
.at("/api/gateway/switch", poem::post(gateway_switch_handler))
|
2026-04-13 13:02:41 +00:00
|
|
|
.at(
|
|
|
|
|
"/mcp",
|
|
|
|
|
poem::post(gateway_mcp_post_handler).get(gateway_mcp_get_handler),
|
|
|
|
|
)
|
|
|
|
|
.at("/health", poem::get(gateway_health_handler))
|
2026-04-14 12:02:17 +00:00
|
|
|
// Agent join endpoints.
|
|
|
|
|
.at("/gateway/mode", poem::get(gateway_mode_handler))
|
|
|
|
|
.at(
|
|
|
|
|
"/gateway/tokens",
|
|
|
|
|
poem::post(gateway_generate_token_handler),
|
|
|
|
|
)
|
|
|
|
|
.at(
|
|
|
|
|
"/gateway/register",
|
|
|
|
|
poem::post(gateway_register_agent_handler),
|
|
|
|
|
)
|
|
|
|
|
.at("/gateway/agents", poem::get(gateway_list_agents_handler))
|
|
|
|
|
.at(
|
|
|
|
|
"/gateway/agents/:id",
|
|
|
|
|
poem::delete(gateway_remove_agent_handler),
|
|
|
|
|
)
|
|
|
|
|
// Serve the embedded React frontend so the gateway has a UI.
|
|
|
|
|
.at(
|
|
|
|
|
"/assets/*path",
|
|
|
|
|
poem::get(crate::http::assets::embedded_asset),
|
|
|
|
|
)
|
|
|
|
|
.at("/*path", poem::get(crate::http::assets::embedded_file))
|
|
|
|
|
.at("/", poem::get(crate::http::assets::embedded_index))
|
2026-04-13 13:02:41 +00:00
|
|
|
.data(state_arc);
|
|
|
|
|
|
|
|
|
|
let host = std::env::var("HUSKIES_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
|
|
|
|
let addr = format!("{host}:{port}");
|
|
|
|
|
crate::slog!("[gateway] Listening on {addr}");
|
|
|
|
|
|
|
|
|
|
poem::Server::new(poem::listener::TcpListener::bind(&addr))
|
|
|
|
|
.run(route)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 09:57:11 +00:00
|
|
|
// ── Matrix bot integration ───────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// Write (or overwrite) a `.mcp.json` in `config_dir` that points Claude Code
|
|
|
|
|
/// CLI at the gateway's own `/mcp` endpoint. This lets the gateway's Matrix
|
|
|
|
|
/// bot use gateway-proxied tool calls instead of a project-specific server.
|
|
|
|
|
fn write_gateway_mcp_json(config_dir: &Path, port: u16) -> Result<(), std::io::Error> {
|
|
|
|
|
let host = std::env::var("HUSKIES_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
|
|
|
|
let url = format!("http://{host}:{port}/mcp");
|
|
|
|
|
let content = serde_json::json!({
|
|
|
|
|
"mcpServers": {
|
|
|
|
|
"huskies": {
|
|
|
|
|
"type": "http",
|
|
|
|
|
"url": url
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
let path = config_dir.join(".mcp.json");
|
|
|
|
|
std::fs::write(&path, serde_json::to_string_pretty(&content).unwrap())?;
|
|
|
|
|
crate::slog!("[gateway] Wrote {} pointing to {}", path.display(), url);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Attempt to spawn the Matrix bot against the gateway config directory.
|
|
|
|
|
///
|
|
|
|
|
/// Reads `<config_dir>/.huskies/bot.toml`. If absent or disabled the function
|
|
|
|
|
/// returns immediately without spawning anything. When the bot is enabled it
|
|
|
|
|
/// receives a shared reference to the gateway's active-project `RwLock` so the
|
|
|
|
|
/// `switch` command can change the active project without going through HTTP.
|
|
|
|
|
fn spawn_gateway_bot(
|
|
|
|
|
config_dir: &Path,
|
|
|
|
|
active_project: ActiveProject,
|
|
|
|
|
gateway_projects: Vec<String>,
|
|
|
|
|
port: u16,
|
|
|
|
|
) {
|
|
|
|
|
use crate::agents::AgentPool;
|
|
|
|
|
use tokio::sync::{broadcast, mpsc};
|
|
|
|
|
|
|
|
|
|
// Create a watcher broadcast channel (no file-system watcher in gateway mode).
|
|
|
|
|
let (watcher_tx, _) = broadcast::channel(16);
|
|
|
|
|
|
|
|
|
|
// Create a dummy permission channel — permission prompts are not forwarded
|
|
|
|
|
// across the proxy boundary in this initial implementation.
|
|
|
|
|
let (_perm_tx, perm_rx) = mpsc::unbounded_channel();
|
|
|
|
|
let perm_rx = Arc::new(tokio::sync::Mutex::new(perm_rx));
|
|
|
|
|
|
|
|
|
|
// Create a shutdown watch channel. Gateway process exit signals Ctrl-C
|
|
|
|
|
// via OS signal, not through a watch channel, so we leave this at None
|
|
|
|
|
// (no shutdown announcement). The sender is kept alive for the duration.
|
|
|
|
|
let (shutdown_tx, shutdown_rx) =
|
|
|
|
|
tokio::sync::watch::channel::<Option<crate::rebuild::ShutdownReason>>(None);
|
|
|
|
|
// Keep sender alive so the receiver is never prematurely closed.
|
|
|
|
|
std::mem::forget(shutdown_tx);
|
|
|
|
|
|
|
|
|
|
let agents = Arc::new(AgentPool::new(port, watcher_tx.clone()));
|
|
|
|
|
|
|
|
|
|
crate::chat::transport::matrix::spawn_bot(
|
|
|
|
|
config_dir,
|
|
|
|
|
watcher_tx,
|
|
|
|
|
perm_rx,
|
|
|
|
|
agents,
|
|
|
|
|
shutdown_rx,
|
|
|
|
|
Some(active_project),
|
|
|
|
|
gateway_projects,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 13:02:41 +00:00
|
|
|
// ── Tests ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_valid_projects_toml() {
|
|
|
|
|
let toml_str = r#"
|
|
|
|
|
[projects.huskies]
|
|
|
|
|
url = "http://localhost:3001"
|
|
|
|
|
|
|
|
|
|
[projects.robot-studio]
|
|
|
|
|
url = "http://localhost:3002"
|
|
|
|
|
"#;
|
|
|
|
|
let config: GatewayConfig = toml::from_str(toml_str).unwrap();
|
|
|
|
|
assert_eq!(config.projects.len(), 2);
|
|
|
|
|
assert_eq!(config.projects["huskies"].url, "http://localhost:3001");
|
|
|
|
|
assert_eq!(config.projects["robot-studio"].url, "http://localhost:3002");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_empty_projects_toml() {
|
|
|
|
|
let toml_str = "[projects]\n";
|
|
|
|
|
let config: GatewayConfig = toml::from_str(toml_str).unwrap();
|
|
|
|
|
assert!(config.projects.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn gateway_state_rejects_empty_config() {
|
2026-04-13 14:07:08 +00:00
|
|
|
let config = GatewayConfig {
|
|
|
|
|
projects: BTreeMap::new(),
|
|
|
|
|
};
|
2026-04-13 13:02:41 +00:00
|
|
|
assert!(GatewayState::new(config).is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn gateway_state_sets_first_project_active() {
|
|
|
|
|
let mut projects = BTreeMap::new();
|
2026-04-13 14:07:08 +00:00
|
|
|
projects.insert(
|
|
|
|
|
"alpha".into(),
|
|
|
|
|
ProjectEntry {
|
|
|
|
|
url: "http://a:3001".into(),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
projects.insert(
|
|
|
|
|
"beta".into(),
|
|
|
|
|
ProjectEntry {
|
|
|
|
|
url: "http://b:3002".into(),
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-04-13 13:02:41 +00:00
|
|
|
let config = GatewayConfig { projects };
|
|
|
|
|
let state = GatewayState::new(config).unwrap();
|
|
|
|
|
let active = state.active_project.blocking_read().clone();
|
|
|
|
|
assert_eq!(active, "alpha"); // BTreeMap sorts alphabetically.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn gateway_tool_definitions_has_expected_tools() {
|
|
|
|
|
let defs = gateway_tool_definitions();
|
2026-04-13 14:07:08 +00:00
|
|
|
let names: Vec<&str> = defs
|
|
|
|
|
.iter()
|
2026-04-13 13:02:41 +00:00
|
|
|
.filter_map(|d| d.get("name").and_then(|n| n.as_str()))
|
|
|
|
|
.collect();
|
|
|
|
|
assert!(names.contains(&"switch_project"));
|
|
|
|
|
assert!(names.contains(&"gateway_status"));
|
|
|
|
|
assert!(names.contains(&"gateway_health"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn switch_project_to_known_project() {
|
|
|
|
|
let mut projects = BTreeMap::new();
|
2026-04-13 14:07:08 +00:00
|
|
|
projects.insert(
|
|
|
|
|
"alpha".into(),
|
|
|
|
|
ProjectEntry {
|
|
|
|
|
url: "http://a:3001".into(),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
projects.insert(
|
|
|
|
|
"beta".into(),
|
|
|
|
|
ProjectEntry {
|
|
|
|
|
url: "http://b:3002".into(),
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-04-13 13:02:41 +00:00
|
|
|
let config = GatewayConfig { projects };
|
|
|
|
|
let state = GatewayState::new(config).unwrap();
|
|
|
|
|
|
|
|
|
|
let params = json!({ "arguments": { "project": "beta" } });
|
|
|
|
|
let resp = handle_switch_project(¶ms, &state).await;
|
|
|
|
|
assert!(resp.result.is_some());
|
|
|
|
|
|
|
|
|
|
let active = state.active_project.read().await.clone();
|
|
|
|
|
assert_eq!(active, "beta");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn switch_project_to_unknown_project_fails() {
|
|
|
|
|
let mut projects = BTreeMap::new();
|
2026-04-13 14:07:08 +00:00
|
|
|
projects.insert(
|
|
|
|
|
"alpha".into(),
|
|
|
|
|
ProjectEntry {
|
|
|
|
|
url: "http://a:3001".into(),
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-04-13 13:02:41 +00:00
|
|
|
let config = GatewayConfig { projects };
|
|
|
|
|
let state = GatewayState::new(config).unwrap();
|
|
|
|
|
|
|
|
|
|
let params = json!({ "arguments": { "project": "nonexistent" } });
|
|
|
|
|
let resp = handle_switch_project(¶ms, &state).await;
|
|
|
|
|
assert!(resp.error.is_some());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn active_url_returns_correct_url() {
|
|
|
|
|
let mut projects = BTreeMap::new();
|
2026-04-13 14:07:08 +00:00
|
|
|
projects.insert(
|
|
|
|
|
"myproj".into(),
|
|
|
|
|
ProjectEntry {
|
|
|
|
|
url: "http://my:3001".into(),
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-04-13 13:02:41 +00:00
|
|
|
let config = GatewayConfig { projects };
|
|
|
|
|
let state = GatewayState::new(config).unwrap();
|
|
|
|
|
|
|
|
|
|
let url = state.active_url().await.unwrap();
|
|
|
|
|
assert_eq!(url, "http://my:3001");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn json_rpc_response_success_serializes() {
|
|
|
|
|
let resp = JsonRpcResponse::success(Some(json!(1)), json!({"ok": true}));
|
|
|
|
|
let s = serde_json::to_string(&resp).unwrap();
|
|
|
|
|
assert!(s.contains("\"result\""));
|
|
|
|
|
assert!(!s.contains("\"error\""));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn json_rpc_response_error_serializes() {
|
|
|
|
|
let resp = JsonRpcResponse::error(Some(json!(1)), -32600, "bad".into());
|
|
|
|
|
let s = serde_json::to_string(&resp).unwrap();
|
|
|
|
|
assert!(s.contains("\"error\""));
|
|
|
|
|
assert!(!s.contains("\"result\""));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn load_config_from_file() {
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
let path = dir.path().join("projects.toml");
|
2026-04-13 14:07:08 +00:00
|
|
|
std::fs::write(
|
|
|
|
|
&path,
|
|
|
|
|
r#"
|
2026-04-13 13:02:41 +00:00
|
|
|
[projects.test]
|
|
|
|
|
url = "http://localhost:9999"
|
2026-04-13 14:07:08 +00:00
|
|
|
"#,
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
2026-04-13 13:02:41 +00:00
|
|
|
|
|
|
|
|
let config = GatewayConfig::load(&path).unwrap();
|
|
|
|
|
assert_eq!(config.projects.len(), 1);
|
|
|
|
|
assert_eq!(config.projects["test"].url, "http://localhost:9999");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn load_config_missing_file_fails() {
|
|
|
|
|
let result = GatewayConfig::load(Path::new("/nonexistent/projects.toml"));
|
|
|
|
|
assert!(result.is_err());
|
|
|
|
|
}
|
2026-04-14 09:57:11 +00:00
|
|
|
|
|
|
|
|
// ── bot.toml in gateway and standalone modes ─────────────────────────
|
|
|
|
|
//
|
|
|
|
|
// Both gateway and standalone modes load bot.toml via `BotConfig::load(dir)`
|
|
|
|
|
// which looks for `dir/.huskies/bot.toml`. These tests document that the
|
|
|
|
|
// same loading convention works from a gateway config directory.
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn bot_config_loads_from_gateway_config_dir() {
|
|
|
|
|
use crate::chat::transport::matrix::BotConfig;
|
|
|
|
|
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let huskies_dir = tmp.path().join(".huskies");
|
|
|
|
|
std::fs::create_dir_all(&huskies_dir).unwrap();
|
|
|
|
|
std::fs::write(
|
|
|
|
|
huskies_dir.join("bot.toml"),
|
|
|
|
|
r#"
|
|
|
|
|
homeserver = "https://matrix.example.com"
|
|
|
|
|
username = "@bot:example.com"
|
|
|
|
|
password = "secret"
|
|
|
|
|
room_ids = ["!abc:example.com"]
|
|
|
|
|
enabled = true
|
|
|
|
|
"#,
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
// Gateway passes config_dir (parent of projects.toml) to spawn_bot,
|
|
|
|
|
// which calls BotConfig::load(config_dir). Verify this resolves correctly.
|
|
|
|
|
let config = BotConfig::load(tmp.path());
|
|
|
|
|
assert!(
|
|
|
|
|
config.is_some(),
|
|
|
|
|
"bot.toml should load from gateway config dir"
|
|
|
|
|
);
|
|
|
|
|
let config = config.unwrap();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
config.homeserver.as_deref(),
|
|
|
|
|
Some("https://matrix.example.com")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn bot_config_absent_returns_none_in_gateway_mode() {
|
|
|
|
|
use crate::chat::transport::matrix::BotConfig;
|
|
|
|
|
|
|
|
|
|
// A gateway config directory without a .huskies/bot.toml should yield None,
|
|
|
|
|
// allowing the gateway to start without a Matrix bot.
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let config = BotConfig::load(tmp.path());
|
|
|
|
|
assert!(
|
|
|
|
|
config.is_none(),
|
|
|
|
|
"absent bot.toml must return None in gateway mode"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn bot_config_disabled_returns_none_in_gateway_mode() {
|
|
|
|
|
use crate::chat::transport::matrix::BotConfig;
|
|
|
|
|
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let huskies_dir = tmp.path().join(".huskies");
|
|
|
|
|
std::fs::create_dir_all(&huskies_dir).unwrap();
|
|
|
|
|
std::fs::write(
|
|
|
|
|
huskies_dir.join("bot.toml"),
|
|
|
|
|
r#"
|
|
|
|
|
homeserver = "https://matrix.example.com"
|
|
|
|
|
username = "@bot:example.com"
|
|
|
|
|
password = "secret"
|
|
|
|
|
room_ids = ["!abc:example.com"]
|
|
|
|
|
enabled = false
|
|
|
|
|
"#,
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let config = BotConfig::load(tmp.path());
|
|
|
|
|
assert!(
|
|
|
|
|
config.is_none(),
|
|
|
|
|
"disabled bot.toml must return None in gateway mode"
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-14 12:02:17 +00:00
|
|
|
|
|
|
|
|
// ── Agent join mechanism tests ───────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
fn make_test_state() -> Arc<GatewayState> {
|
|
|
|
|
let mut projects = BTreeMap::new();
|
|
|
|
|
projects.insert(
|
|
|
|
|
"test".into(),
|
|
|
|
|
ProjectEntry {
|
|
|
|
|
url: "http://test:3001".into(),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
let config = GatewayConfig { projects };
|
|
|
|
|
Arc::new(GatewayState::new(config).unwrap())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn generate_token_creates_pending_token() {
|
|
|
|
|
let state = make_test_state();
|
|
|
|
|
let app = poem::Route::new()
|
|
|
|
|
.at(
|
|
|
|
|
"/gateway/tokens",
|
|
|
|
|
poem::post(gateway_generate_token_handler),
|
|
|
|
|
)
|
|
|
|
|
.data(state.clone());
|
|
|
|
|
let cli = poem::test::TestClient::new(app);
|
|
|
|
|
let resp = cli.post("/gateway/tokens").send().await;
|
|
|
|
|
assert_eq!(resp.0.status(), StatusCode::OK);
|
|
|
|
|
let body: Value = resp.0.into_body().into_json().await.unwrap();
|
|
|
|
|
let token = body["token"].as_str().unwrap();
|
|
|
|
|
assert!(!token.is_empty());
|
|
|
|
|
let tokens = state.pending_tokens.read().await;
|
|
|
|
|
assert!(tokens.contains_key(token));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn register_agent_consumes_token() {
|
|
|
|
|
let state = make_test_state();
|
|
|
|
|
|
|
|
|
|
// Insert a token manually.
|
|
|
|
|
let token = "test-token-123".to_string();
|
|
|
|
|
state.pending_tokens.write().await.insert(
|
|
|
|
|
token.clone(),
|
|
|
|
|
PendingToken {
|
|
|
|
|
created_at: chrono::Utc::now().timestamp() as f64,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let app = poem::Route::new()
|
|
|
|
|
.at(
|
|
|
|
|
"/gateway/register",
|
|
|
|
|
poem::post(gateway_register_agent_handler),
|
|
|
|
|
)
|
|
|
|
|
.data(state.clone());
|
|
|
|
|
let cli = poem::test::TestClient::new(app);
|
|
|
|
|
let resp = cli
|
|
|
|
|
.post("/gateway/register")
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
.body(
|
|
|
|
|
json!({
|
|
|
|
|
"token": token,
|
|
|
|
|
"label": "test-agent",
|
|
|
|
|
"address": "ws://localhost:3001/crdt-sync"
|
|
|
|
|
})
|
|
|
|
|
.to_string(),
|
|
|
|
|
)
|
|
|
|
|
.send()
|
|
|
|
|
.await;
|
|
|
|
|
assert_eq!(resp.0.status(), StatusCode::OK);
|
|
|
|
|
|
|
|
|
|
// Token consumed.
|
|
|
|
|
assert!(state.pending_tokens.read().await.is_empty());
|
|
|
|
|
// Agent registered.
|
|
|
|
|
let agents = state.joined_agents.read().await;
|
|
|
|
|
assert_eq!(agents.len(), 1);
|
|
|
|
|
assert_eq!(agents[0].label, "test-agent");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn register_agent_rejects_invalid_token() {
|
|
|
|
|
let state = make_test_state();
|
|
|
|
|
let app = poem::Route::new()
|
|
|
|
|
.at(
|
|
|
|
|
"/gateway/register",
|
|
|
|
|
poem::post(gateway_register_agent_handler),
|
|
|
|
|
)
|
|
|
|
|
.data(state.clone());
|
|
|
|
|
let cli = poem::test::TestClient::new(app);
|
|
|
|
|
let resp = cli
|
|
|
|
|
.post("/gateway/register")
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
.body(
|
|
|
|
|
json!({
|
|
|
|
|
"token": "bad-token",
|
|
|
|
|
"label": "agent",
|
|
|
|
|
"address": "ws://localhost:3001/crdt-sync"
|
|
|
|
|
})
|
|
|
|
|
.to_string(),
|
|
|
|
|
)
|
|
|
|
|
.send()
|
|
|
|
|
.await;
|
|
|
|
|
assert_eq!(resp.0.status(), StatusCode::UNAUTHORIZED);
|
|
|
|
|
assert!(state.joined_agents.read().await.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn list_agents_returns_registered_agents() {
|
|
|
|
|
let state = make_test_state();
|
|
|
|
|
state.joined_agents.write().await.push(JoinedAgent {
|
|
|
|
|
id: "id-1".into(),
|
|
|
|
|
label: "agent-1".into(),
|
|
|
|
|
address: "ws://a:3001/crdt-sync".into(),
|
|
|
|
|
registered_at: 0.0,
|
|
|
|
|
});
|
|
|
|
|
let app = poem::Route::new()
|
|
|
|
|
.at("/gateway/agents", poem::get(gateway_list_agents_handler))
|
|
|
|
|
.data(state.clone());
|
|
|
|
|
let cli = poem::test::TestClient::new(app);
|
|
|
|
|
let resp = cli.get("/gateway/agents").send().await;
|
|
|
|
|
assert_eq!(resp.0.status(), StatusCode::OK);
|
|
|
|
|
let agents: Vec<Value> = resp.0.into_body().into_json().await.unwrap();
|
|
|
|
|
assert_eq!(agents.len(), 1);
|
|
|
|
|
assert_eq!(agents[0]["label"], "agent-1");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn remove_agent_deletes_by_id() {
|
|
|
|
|
let state = make_test_state();
|
|
|
|
|
state.joined_agents.write().await.push(JoinedAgent {
|
|
|
|
|
id: "del-id".into(),
|
|
|
|
|
label: "to-delete".into(),
|
|
|
|
|
address: "ws://x:3001/crdt-sync".into(),
|
|
|
|
|
registered_at: 0.0,
|
|
|
|
|
});
|
|
|
|
|
let app = poem::Route::new()
|
|
|
|
|
.at(
|
|
|
|
|
"/gateway/agents/:id",
|
|
|
|
|
poem::delete(gateway_remove_agent_handler),
|
|
|
|
|
)
|
|
|
|
|
.data(state.clone());
|
|
|
|
|
let cli = poem::test::TestClient::new(app);
|
|
|
|
|
let resp = cli.delete("/gateway/agents/del-id").send().await;
|
|
|
|
|
assert_eq!(resp.0.status(), StatusCode::NO_CONTENT);
|
|
|
|
|
assert!(state.joined_agents.read().await.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn remove_agent_unknown_id_returns_not_found() {
|
|
|
|
|
let state = make_test_state();
|
|
|
|
|
let app = poem::Route::new()
|
|
|
|
|
.at(
|
|
|
|
|
"/gateway/agents/:id",
|
|
|
|
|
poem::delete(gateway_remove_agent_handler),
|
|
|
|
|
)
|
|
|
|
|
.data(state.clone());
|
|
|
|
|
let cli = poem::test::TestClient::new(app);
|
|
|
|
|
let resp = cli.delete("/gateway/agents/no-such-id").send().await;
|
|
|
|
|
assert_eq!(resp.0.status(), StatusCode::NOT_FOUND);
|
|
|
|
|
}
|
2026-04-13 13:02:41 +00:00
|
|
|
}
|