diff --git a/server/src/gateway.rs b/server/src/gateway.rs index 8be508bd..fc132c6c 100644 --- a/server/src/gateway.rs +++ b/server/src/gateway.rs @@ -1,2032 +1,21 @@ -//! Multi-project gateway — proxies MCP calls to per-project Docker containers. +//! Multi-project gateway — entrypoint wiring and route tree. //! -//! 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. +//! When `huskies --gateway` is used, the server starts in gateway mode. +//! Business logic lives in `service::gateway`, HTTP handlers in `http::gateway`. +//! This file contains only the `run` entrypoint and `build_gateway_route` wiring. +use crate::http::gateway::*; +use crate::service::gateway::{self, GatewayState}; use poem::EndpointExt; -use poem::handler; -use poem::http::StatusCode; -use poem::web::Path as PoemPath; -use poem::web::{Data, Json}; -use poem::{Body, Request, Response}; -use reqwest::Client; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use std::collections::BTreeMap; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::Arc; -use tokio::sync::Mutex as TokioMutex; -use tokio::sync::RwLock; -use uuid::Uuid; -// Re-export active_project type alias for clarity in gateway bot helpers. -type ActiveProject = Arc>; - -// ── 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, -} - -impl GatewayConfig { - /// Load gateway config from a `projects.toml` file. - pub fn load(path: &Path) -> Result { - let contents = std::fs::read_to_string(path) - .map_err(|e| format!("cannot read {}: {e}", path.display()))?; - toml::from_str(&contents).map_err(|e| format!("invalid projects.toml: {e}")) - } -} - -// ── 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, - /// Unix timestamp of the last heartbeat from this agent. Defaults to `registered_at` - /// for agents loaded from persisted state that predate the heartbeat feature. - #[serde(default)] - pub last_seen: f64, - /// Project this agent is assigned to, if any. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub assigned_project: Option, -} - -/// 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, -} - -/// Request body for assigning or reassigning an agent to a project. -/// -/// Send `{"project": "my-project"}` to assign, or `{"project": null}` to unassign. -#[derive(Deserialize)] -struct AssignAgentRequest { - project: Option, -} - -// ── Gateway state ──────────────────────────────────────────────────── - -/// Shared gateway state threaded through HTTP handlers. -#[derive(Clone)] -pub struct GatewayState { - /// The live set of registered projects (initially loaded from `projects.toml`). - pub projects: Arc>>, - /// The currently active project name. - pub active_project: Arc>, - /// HTTP client for proxying requests to project containers. - pub client: Client, - /// Build agents that have joined this gateway. - pub joined_agents: Arc>>, - /// One-time join tokens that have been issued but not yet consumed. - pending_tokens: Arc>>, - /// Directory containing `projects.toml` and the `.huskies/` subfolder. - pub config_dir: PathBuf, - /// HTTP port the gateway is listening on. - pub port: u16, - /// Abort handle for the running Matrix bot task (if any). - /// Stored so the bot can be restarted when credentials change. - pub bot_handle: Arc>>, -} - -/// Load persisted agents from `/gateway_agents.json`. -/// Returns an empty list if the file does not exist or cannot be parsed. -fn load_agents(config_dir: &Path) -> Vec { - let path = config_dir.join("gateway_agents.json"); - match std::fs::read(&path) { - Ok(data) => serde_json::from_slice(&data).unwrap_or_default(), - Err(_) => Vec::new(), - } -} - -/// Persist the current projects map to `/projects.toml`. -/// Silently ignores write errors or skips when `config_dir` is empty. -async fn save_config(projects: &BTreeMap, config_dir: &Path) { - if config_dir.as_os_str().is_empty() { - return; - } - let path = config_dir.join("projects.toml"); - let config = GatewayConfig { - projects: projects.clone(), - }; - if let Ok(data) = toml::to_string_pretty(&config) { - let _ = tokio::fs::write(&path, data).await; - } -} - -/// Persist the current agent list to `/gateway_agents.json`. -/// Silently ignores write errors (e.g. read-only filesystem or empty path). -async fn save_agents(agents: &[JoinedAgent], config_dir: &Path) { - if config_dir == Path::new("") { - return; - } - let path = config_dir.join("gateway_agents.json"); - if let Ok(data) = serde_json::to_vec_pretty(agents) { - let _ = tokio::fs::write(&path, data).await; - } -} - -impl GatewayState { - /// Create a new gateway state from a config and config directory. - /// - /// The first project in the config becomes the active project by default. - /// Previously registered agents are loaded from `gateway_agents.json` in - /// `config_dir` if the file exists. - pub fn new(config: GatewayConfig, config_dir: PathBuf, port: u16) -> Result { - 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(); - let agents = load_agents(&config_dir); - Ok(Self { - projects: Arc::new(RwLock::new(config.projects)), - active_project: Arc::new(RwLock::new(first)), - client: Client::new(), - joined_agents: Arc::new(RwLock::new(agents)), - pending_tokens: Arc::new(RwLock::new(HashMap::new())), - config_dir, - port, - bot_handle: Arc::new(TokioMutex::new(None)), - }) - } - - /// Get the URL of the currently active project. - async fn active_url(&self) -> Result { - let name = self.active_project.read().await.clone(); - self.projects - .read() - .await - .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, - 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, - #[serde(skip_serializing_if = "Option::is_none")] - result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, -} - -#[derive(Debug, Serialize)] -struct JsonRpcError { - code: i64, - message: String, -} - -impl JsonRpcResponse { - fn success(id: Option, result: Value) -> Self { - Self { - jsonrpc: "2.0", - id, - result: Some(result), - error: None, - } - } - - fn error(id: Option, code: i64, message: String) -> Self { - Self { - jsonrpc: "2.0", - id, - result: None, - error: Some(JsonRpcError { code, message }), - } - } -} - -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", - "init_project", - "aggregate_pipeline_status", -]; - -/// 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>, -) -> 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( - None, - -32700, - "Unsupported Content-Type; expected application/json".into(), - )); - } - - let bytes = match body.into_bytes().await { - Ok(b) => b, - Err(_) => { - return to_json_response(JsonRpcResponse::error(None, -32700, "Parse error".into())); - } - }; - - let rpc: JsonRpcRequest = match serde_json::from_slice(&bytes) { - Ok(r) => r, - Err(_) => { - return to_json_response(JsonRpcResponse::error(None, -32700, "Parse error".into())); - } - }; - - if rpc.jsonrpc != "2.0" { - return to_json_response(JsonRpcResponse::error( - rpc.id, - -32600, - "Invalid JSON-RPC version".into(), - )); - } - - // 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" => { - let tool_name = rpc - .params - .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, rpc.id.clone()).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( - rpc.id, - -32603, - format!("proxy error: {e}"), - )), - } - } - } - _ => { - // 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( - rpc.id, - -32603, - format!("proxy error: {e}"), - )), - } - } - } -} - -/// 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) -> 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 { - 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": {} - } - }), - json!({ - "name": "init_project", - "description": "Initialize a new huskies project at the given path by scaffolding .huskies/ and related files — the same as running `huskies init `. Prefer this tool over asking the user to run the CLI. If `name` and `url` are supplied the project is also registered in projects.toml so switch_project can reach it immediately.", - "inputSchema": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Absolute filesystem path to the project directory to initialise. The directory is created if it does not exist." - }, - "name": { - "type": "string", - "description": "Optional: short name to register the project under in projects.toml (e.g. 'my-app'). Requires `url`." - }, - "url": { - "type": "string", - "description": "Optional: base URL of the huskies container that will serve this project (e.g. 'http://my-app:3001'). Required when `name` is given." - } - }, - "required": ["path"] - } - }), - json!({ - "name": "aggregate_pipeline_status", - "description": "Fetch pipeline status from ALL registered projects in parallel and return an aggregated report. For each project: stage counts (backlog/current/qa/merge/done) and a list of blocked or failing items with triage detail. Unreachable projects are included with an error state rather than failing the whole call.", - "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, -) -> Result { - 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": {} - }); - - let resp = state - .client - .post(&mcp_url) - .json(&rpc_body) - .send() - .await - .map_err(|e| format!("failed to reach {mcp_url}: {e}"))?; - - let resp_json: Value = resp - .json() - .await - .map_err(|e| format!("invalid JSON from upstream: {e}"))?; - - let mut tools: Vec = 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. -async fn proxy_mcp_call(state: &GatewayState, request_bytes: &[u8]) -> Result, String> { - let url = state.active_url().await?; - let mcp_url = format!("{}/mcp", url.trim_end_matches('/')); - - let resp = state - .client - .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, - id: Option, -) -> JsonRpcResponse { - match tool_name { - "switch_project" => handle_switch_project(params, state, id).await, - "gateway_status" => handle_gateway_status(state, id).await, - "gateway_health" => handle_gateway_health(state, id).await, - "init_project" => handle_init_project(params, state, id).await, - "aggregate_pipeline_status" => handle_aggregate_pipeline_status(state, id).await, - _ => JsonRpcResponse::error(id, -32601, format!("Unknown gateway tool: {tool_name}")), - } -} - -/// Switch the active project. -async fn handle_switch_project( - params: &Value, - state: &GatewayState, - id: Option, -) -> 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(id, -32602, "missing required parameter: project".into()); - } - - let url = { - let projects = state.projects.read().await; - if !projects.contains_key(project) { - let available: Vec<&str> = projects.keys().map(|s| s.as_str()).collect(); - return JsonRpcResponse::error( - id, - -32602, - format!( - "unknown project '{project}'. Available: {}", - available.join(", ") - ), - ); - } - projects[project].url.clone() - }; - - *state.active_project.write().await = project.to_string(); - - JsonRpcResponse::success( - id, - 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, id: Option) -> JsonRpcResponse { - let active = state.active_project.read().await.clone(); - let url = match state.active_url().await { - Ok(u) => u, - Err(e) => return JsonRpcResponse::error(id.clone(), -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::().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( - id, - json!({ - "content": [{ - "type": "text", - "text": format!( - "Pipeline status for '{active}':\n{}", - serde_json::to_string_pretty(&pipeline).unwrap_or_default() - ) - }] - }), - ) - } - Err(e) => { - JsonRpcResponse::error(id, -32603, format!("invalid upstream response: {e}")) - } - } - } - Err(e) => JsonRpcResponse::error(id, -32603, format!("failed to reach {mcp_url}: {e}")), - } -} - -/// Aggregate health checks across all registered projects. -async fn handle_gateway_health(state: &GatewayState, id: Option) -> JsonRpcResponse { - let mut results = BTreeMap::new(); - - let project_entries: Vec<(String, String)> = state - .projects - .read() - .await - .iter() - .map(|(n, e)| (n.clone(), e.url.clone())) - .collect(); - for (name, url) in &project_entries { - let health_url = format!("{}/health", 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( - id, - json!({ - "content": [{ - "type": "text", - "text": format!( - "Health check (active: '{active}'):\n{}", - results.iter() - .map(|(name, status)| format!(" {name}: {status}")) - .collect::>() - .join("\n") - ) - }] - }), - ) -} - -// ── Aggregate pipeline status ───────────────────────────────────────── - -/// Fetch `get_pipeline_status` from every registered project URL in parallel. -/// -/// Returns a `BTreeMap` of project name → per-project status JSON. Each value -/// is either `{"counts": {...}, "blocked": [...]}` on success or -/// `{"error": "..."}` when the project container is unreachable or returns an -/// unexpected response. A single flaky project never causes the whole call to -/// fail. -pub async fn fetch_all_project_pipeline_statuses( - project_urls: &BTreeMap, - client: &Client, -) -> BTreeMap { - use futures::future::join_all; - - let futures: Vec<_> = project_urls - .iter() - .map(|(name, url)| { - let name = name.clone(); - let url = url.clone(); - let client = client.clone(); - async move { - let result = fetch_one_project_pipeline_status(&url, &client).await; - (name, result) - } - }) - .collect(); - - join_all(futures).await.into_iter().collect() -} - -/// Fetch and aggregate pipeline status for a single project URL. -async fn fetch_one_project_pipeline_status(url: &str, client: &Client) -> Value { - 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 client.post(&mcp_url).json(&rpc_body).send().await { - Ok(resp) => match resp.json::().await { - Ok(upstream) => { - if let Some(text) = upstream - .get("result") - .and_then(|r| r.get("content")) - .and_then(|c| c.get(0)) - .and_then(|c| c.get("text")) - .and_then(|t| t.as_str()) - { - match serde_json::from_str::(text) { - Ok(pipeline) => aggregate_pipeline_counts(&pipeline), - Err(_) => json!({ "error": "invalid pipeline JSON" }), - } - } else { - json!({ "error": "unexpected response shape" }) - } - } - Err(e) => json!({ "error": format!("invalid response: {e}") }), - }, - Err(e) => json!({ "error": format!("unreachable: {e}") }), - } -} - -/// Parse a `get_pipeline_status` JSON payload and produce aggregated counts -/// plus a list of blocked/failing items. -fn aggregate_pipeline_counts(pipeline: &Value) -> Value { - let active = pipeline - .get("active") - .and_then(|a| a.as_array()) - .cloned() - .unwrap_or_default(); - let backlog_count = pipeline - .get("backlog_count") - .and_then(|n| n.as_u64()) - .unwrap_or(0); - - let mut current = 0u64; - let mut qa = 0u64; - let mut merge = 0u64; - let mut done = 0u64; - let mut blocked: Vec = Vec::new(); - - for item in &active { - let stage = item - .get("stage") - .and_then(|s| s.as_str()) - .unwrap_or("unknown"); - match stage { - "current" => current += 1, - "qa" => qa += 1, - "merge" => merge += 1, - "done" => done += 1, - _ => {} - } - - let is_blocked = item - .get("blocked") - .and_then(|b| b.as_bool()) - .unwrap_or(false); - let merge_failure = item.get("merge_failure"); - let has_merge_failure = merge_failure - .map(|f| !f.is_null() && f != "") - .unwrap_or(false); - - if is_blocked || has_merge_failure { - let story_id = item - .get("story_id") - .and_then(|s| s.as_str()) - .unwrap_or("?") - .to_string(); - let story_name = item - .get("name") - .and_then(|s| s.as_str()) - .unwrap_or("") - .to_string(); - let reason = if has_merge_failure { - format!( - "merge failure: {}", - merge_failure.and_then(|f| f.as_str()).unwrap_or("unknown") - ) - } else { - let rc = item - .get("retry_count") - .and_then(|n| n.as_u64()) - .unwrap_or(0); - format!("blocked after {rc} retries") - }; - blocked.push(json!({ - "story_id": story_id, - "name": story_name, - "stage": stage, - "reason": reason, - })); - } - } - - json!({ - "counts": { - "backlog": backlog_count, - "current": current, - "qa": qa, - "merge": merge, - "done": done, - }, - "blocked": blocked, - }) -} - -/// Format an aggregated status map as a compact, one-line-per-project string -/// suitable for Matrix/Slack messages. -/// -/// Healthy projects: `🟢 **name** — B:5 C:2 Q:1 M:0 D:12` -/// Blocked items appended on the same line: `| blocked: 42 [story]` -/// Unreachable projects: `🔴 **name** — UNREACHABLE` -pub fn format_aggregate_status_compact(statuses: &BTreeMap) -> String { - let mut lines: Vec = Vec::new(); - for (name, status) in statuses { - if let Some(err) = status.get("error").and_then(|e| e.as_str()) { - lines.push(format!("\u{1F534} **{name}** — UNREACHABLE: {err}")); - } else { - let counts = status.get("counts"); - let b = counts - .and_then(|c| c.get("backlog")) - .and_then(|n| n.as_u64()) - .unwrap_or(0); - let c = counts - .and_then(|c| c.get("current")) - .and_then(|n| n.as_u64()) - .unwrap_or(0); - let q = counts - .and_then(|c| c.get("qa")) - .and_then(|n| n.as_u64()) - .unwrap_or(0); - let m = counts - .and_then(|c| c.get("merge")) - .and_then(|n| n.as_u64()) - .unwrap_or(0); - let d = counts - .and_then(|c| c.get("done")) - .and_then(|n| n.as_u64()) - .unwrap_or(0); - - let blocked_arr = status - .get("blocked") - .and_then(|a| a.as_array()) - .cloned() - .unwrap_or_default(); - - let indicator = if blocked_arr.is_empty() { - "\u{1F7E2}" // 🟢 - } else { - "\u{1F7E0}" // 🟠 - }; - - let mut line = format!("{indicator} **{name}** — B:{b} C:{c} Q:{q} M:{m} D:{d}"); - - if !blocked_arr.is_empty() { - let ids: Vec = blocked_arr - .iter() - .filter_map(|item| item.get("story_id").and_then(|s| s.as_str())) - .map(|s| s.to_string()) - .collect(); - line.push_str(&format!(" | blocked: {}", ids.join(", "))); - } - - lines.push(line); - } - } - if lines.is_empty() { - return "No projects registered.".to_string(); - } - format!("**All Projects**\n\n{}", lines.join("\n\n")) -} - -/// MCP tool handler for `aggregate_pipeline_status`. -async fn handle_aggregate_pipeline_status( - state: &GatewayState, - id: Option, -) -> JsonRpcResponse { - let project_urls: BTreeMap = state - .projects - .read() - .await - .iter() - .map(|(name, entry)| (name.clone(), entry.url.clone())) - .collect(); - - let statuses = fetch_all_project_pipeline_statuses(&project_urls, &state.client).await; - let active = state.active_project.read().await.clone(); - - JsonRpcResponse::success( - id, - json!({ - "content": [{ - "type": "text", - "text": format!( - "Aggregate pipeline status (active: '{active}'):\n{}", - serde_json::to_string_pretty(&statuses).unwrap_or_default() - ) - }], - "projects": statuses, - "active": active, - }), - ) -} - -/// Initialise a new huskies project at the given filesystem path. -/// -/// Performs the same scaffolding as `huskies init `: creates `.huskies/`, -/// default config files, pipeline directories, and the wizard state. If `name` -/// and `url` are both provided the new project is also registered in -/// `projects.toml` so `switch_project` can reach it immediately. -/// -/// Returns an error when the path already contains a `.huskies/` directory. -/// After success the tool response tells the caller what to do next to make -/// `wizard_*` MCP tools work against the new project. -async fn handle_init_project( - params: &Value, - state: &GatewayState, - id: Option, -) -> JsonRpcResponse { - let args = params.get("arguments").unwrap_or(params); - - let path_str = args - .get("path") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim(); - - if path_str.is_empty() { - return JsonRpcResponse::error(id, -32602, "missing required parameter: path".to_string()); - } - - let project_path = std::path::Path::new(path_str); - - // Guard: already a huskies project. - if project_path.join(".huskies").exists() { - return JsonRpcResponse::error( - id, - -32602, - format!( - "path '{}' is already a huskies project (.huskies/ exists). \ - Use wizard_status to check setup progress.", - project_path.display() - ), - ); - } - - // Create directory if it does not yet exist. - if !project_path.exists() - && let Err(e) = std::fs::create_dir_all(project_path) - { - return JsonRpcResponse::error( - id, - -32603, - format!( - "failed to create directory '{}': {e}", - project_path.display() - ), - ); - } - - // Scaffold .huskies/ — same logic as `huskies init`. - // Port 3001 is written into .mcp.json only when the file is absent; if it - // already exists it is never overwritten (the value is environment-specific). - if let Err(e) = crate::io::fs::scaffold::scaffold_story_kit(project_path, 3001) { - return JsonRpcResponse::error(id, -32603, format!("scaffold failed: {e}")); - } - - // Initialise wizard state so wizard_status returns a valid response - // immediately after the project server is started. - crate::io::wizard::WizardState::init_if_missing(project_path); - - // Optionally register the project in projects.toml. - let name = args.get("name").and_then(|v| v.as_str()).map(str::trim); - let url = args.get("url").and_then(|v| v.as_str()).map(str::trim); - - let registered_name: Option = match (name, url) { - (Some(n), Some(u)) if !n.is_empty() && !u.is_empty() => { - let mut projects = state.projects.write().await; - if projects.contains_key(n) { - return JsonRpcResponse::error( - id, - -32602, - format!( - "project '{n}' is already registered. \ - Choose a different name or use switch_project." - ), - ); - } - projects.insert(n.to_string(), ProjectEntry { url: u.to_string() }); - save_config(&projects, &state.config_dir).await; - crate::slog!("[gateway] init_project: registered '{n}' ({u})"); - Some(n.to_string()) - } - _ => None, - }; - - let next_steps = if let Some(ref n) = registered_name { - format!( - "Project registered as '{n}' in projects.toml.\n\ - Next steps:\n\ - 1. Start a huskies server at '{path_str}' \ - (e.g. `huskies {path_str}` or via Docker).\n\ - 2. Call switch_project with name='{n}' to make it active.\n\ - 3. Call wizard_status to begin the setup wizard." - ) - } else { - format!( - "Next steps:\n\ - 1. Start a huskies server at '{path_str}' \ - (e.g. `huskies {path_str}` or via Docker).\n\ - 2. Register the project: call init_project again with name and url \ - parameters, or add it to projects.toml manually.\n\ - 3. Call switch_project and then wizard_status to begin the setup wizard.\n\n\ - Note: wizard_* MCP tools require a running huskies server for the project." - ) - }; - - JsonRpcResponse::success( - id, - json!({ - "content": [{ - "type": "text", - "text": format!( - "Successfully initialised huskies project at '{path_str}'.\n\n{next_steps}" - ) - }] - }), - ) -} - -// ── 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": ""}`. 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>) -> 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>, -) -> 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 now = chrono::Utc::now().timestamp() as f64; - let agent = JoinedAgent { - id: Uuid::new_v4().to_string(), - label: req.label, - address: req.address, - registered_at: now, - last_seen: now, - assigned_project: None, - }; - - crate::slog!( - "[gateway] Agent '{}' registered (id={})", - agent.label, - agent.id - ); - - { - let mut agents = state.joined_agents.write().await; - agents.push(agent.clone()); - save_agents(&agents, &state.config_dir).await; - } - - 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>) -> 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, - state: Data<&Arc>, -) -> Response { - let removed = { - let mut agents = state.joined_agents.write().await; - let before = agents.len(); - agents.retain(|a| a.id != id); - let removed = agents.len() < before; - if removed { - save_agents(&agents, &state.config_dir).await; - } - removed - }; - - 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")) - } -} - -/// `POST /gateway/agents/:id/assign` — assign or unassign an agent to a project. -/// -/// Body: `{ "project": "my-project" }` to assign, or `{ "project": null }` to unassign. -/// Returns the updated `JoinedAgent` on success. The assignment is persisted to disk -/// so it survives gateway restarts. -#[handler] -pub async fn gateway_assign_agent_handler( - PoemPath(id): PoemPath, - body: Json, - state: Data<&Arc>, -) -> Response { - let project = body - .0 - .project - .and_then(|p| if p.is_empty() { None } else { Some(p) }); - - if let Some(ref p) = project - && !state.projects.read().await.contains_key(p.as_str()) - { - return Response::builder() - .status(StatusCode::BAD_REQUEST) - .body(Body::from(format!("unknown project '{p}'"))); - } - - let updated = { - let mut agents = state.joined_agents.write().await; - match agents.iter_mut().find(|a| a.id == id) { - None => None, - Some(a) => { - a.assigned_project = project; - Some(a.clone()) - } - } - }; - - match updated { - None => Response::builder() - .status(StatusCode::NOT_FOUND) - .body(Body::from("agent not found")), - Some(agent) => { - crate::slog!( - "[gateway] Agent '{}' (id={}) assigned to {:?}", - agent.label, - agent.id, - agent.assigned_project - ); - let agents = state.joined_agents.read().await.clone(); - save_agents(&agents, &state.config_dir).await; - let body = serde_json::to_vec(&agent).unwrap_or_default(); - Response::builder() - .status(StatusCode::OK) - .header("Content-Type", "application/json") - .body(Body::from(body)) - } - } -} - -/// `POST /gateway/agents/:id/heartbeat` — update an agent's last-seen timestamp. -/// -/// Build agents should call this periodically (e.g. every 30 s) so the gateway -/// can distinguish live agents from disconnected ones. Returns 204 No Content on -/// success or 404 if the agent ID is not found. -#[handler] -pub async fn gateway_heartbeat_handler( - PoemPath(id): PoemPath, - state: Data<&Arc>, -) -> Response { - let found = { - let mut agents = state.joined_agents.write().await; - match agents.iter_mut().find(|a| a.id == id) { - None => false, - Some(a) => { - a.last_seen = chrono::Utc::now().timestamp() as f64; - true - } - } - }; - - if found { - Response::builder() - .status(StatusCode::NO_CONTENT) - .body(Body::empty()) - } else { - Response::builder() - .status(StatusCode::NOT_FOUND) - .body(Body::from("agent not found")) - } -} - -// ── 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>) -> Response { - let mut all_healthy = true; - let mut statuses = BTreeMap::new(); - - let project_entries: Vec<(String, String)> = state - .projects - .read() - .await - .iter() - .map(|(n, e)| (n.clone(), e.url.clone())) - .collect(); - for (name, url) in &project_entries { - let health_url = format!("{}/health", 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, - }); - - let status = if all_healthy { - StatusCode::OK - } else { - StatusCode::SERVICE_UNAVAILABLE - }; - Response::builder() - .status(status) - .header("Content-Type", "application/json") - .body(Body::from(serde_json::to_vec(&body).unwrap_or_default())) -} - -// ── 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#" - - - - -Huskies Gateway - - - -
-
- -
-

Huskies Gateway

-
Multi-project orchestration
-
-
- - - -
- -
- - - -"#; - -/// 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>) -> Response { - let active = state.active_project.read().await.clone(); - let projects: Vec = state - .projects - .read() - .await - .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>, - body: Json, -) -> Response { - let params = json!({ "arguments": { "project": body.project } }); - let resp = handle_switch_project(¶ms, &state, None).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(), - )) -} - -// ── Project management API ─────────────────────────────────────────── - -/// Request body for adding a new project. -#[derive(Deserialize)] -struct AddProjectRequest { - name: String, - url: String, -} - -/// `POST /api/gateway/projects` — add a new project to the gateway config. -/// -/// Expects JSON `{ "name": "...", "url": "..." }`. Returns the created project -/// or 409 Conflict if a project with the same name already exists. -#[handler] -pub async fn gateway_add_project_handler( - state: Data<&Arc>, - body: Json, -) -> Response { - let name = body.0.name.trim().to_string(); - let url = body.0.url.trim().to_string(); - - if name.is_empty() { - return Response::builder() - .status(StatusCode::BAD_REQUEST) - .body(Body::from("project name must not be empty")); - } - if url.is_empty() { - return Response::builder() - .status(StatusCode::BAD_REQUEST) - .body(Body::from("project url must not be empty")); - } - - { - let mut projects = state.projects.write().await; - if projects.contains_key(&name) { - return Response::builder() - .status(StatusCode::CONFLICT) - .body(Body::from(format!("project '{name}' already exists"))); - } - projects.insert(name.clone(), ProjectEntry { url: url.clone() }); - } - - let snapshot = state.projects.read().await.clone(); - save_config(&snapshot, &state.config_dir).await; - - crate::slog!("[gateway] Added project '{name}' ({url})"); - let body_val = json!({ "name": name, "url": url }); - Response::builder() - .status(StatusCode::OK) - .header("Content-Type", "application/json") - .body(Body::from( - serde_json::to_vec(&body_val).unwrap_or_default(), - )) -} - -/// `DELETE /api/gateway/projects/:name` — remove a project from the gateway config. -/// -/// Returns 204 No Content on success. Returns 400 if this is the last project -/// (the gateway requires at least one project to remain configured). -#[handler] -pub async fn gateway_remove_project_handler( - PoemPath(name): PoemPath, - state: Data<&Arc>, -) -> Response { - let active = state.active_project.read().await.clone(); - - { - let mut projects = state.projects.write().await; - if !projects.contains_key(&name) { - return Response::builder() - .status(StatusCode::NOT_FOUND) - .body(Body::from(format!("project '{name}' not found"))); - } - if projects.len() == 1 { - return Response::builder() - .status(StatusCode::BAD_REQUEST) - .body(Body::from("cannot remove the last project")); - } - projects.remove(&name); - } - - let snapshot = state.projects.read().await.clone(); - save_config(&snapshot, &state.config_dir).await; - - // If the removed project was active, switch to the first remaining. - if active == name { - let first = state.projects.read().await.keys().next().cloned(); - if let Some(new_active) = first { - *state.active_project.write().await = new_active; - } - } - - crate::slog!("[gateway] Removed project '{name}'"); - Response::builder() - .status(StatusCode::NO_CONTENT) - .body(Body::empty()) -} - -// ── Bot configuration API ──────────────────────────────────────────── - -/// Request/response body for the bot configuration API. -#[derive(Deserialize, Serialize, Default)] -struct BotConfigPayload { - /// Chat transport: `"matrix"` or `"slack"`. - transport: String, - // Matrix fields - homeserver: Option, - username: Option, - password: Option, - // Slack fields - slack_bot_token: Option, - slack_signing_secret: Option, -} - -/// Read the current raw bot.toml (without validation) as key/value pairs for -/// the configuration UI. Returns an empty payload if the file does not exist. -fn read_bot_config_raw(config_dir: &Path) -> BotConfigPayload { - let path = config_dir.join(".huskies").join("bot.toml"); - let content = match std::fs::read_to_string(&path) { - Ok(c) => c, - Err(_) => return BotConfigPayload::default(), - }; - let table: toml::Value = match toml::from_str(&content) { - Ok(v) => v, - Err(_) => return BotConfigPayload::default(), - }; - let s = |key: &str| -> Option { - table - .get(key) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - }; - BotConfigPayload { - transport: s("transport").unwrap_or_else(|| "matrix".to_string()), - homeserver: s("homeserver"), - username: s("username"), - password: s("password"), - slack_bot_token: s("slack_bot_token"), - slack_signing_secret: s("slack_signing_secret"), - } -} - -/// Write a `bot.toml` from the given payload. -fn write_bot_config(config_dir: &Path, payload: &BotConfigPayload) -> Result<(), String> { - let huskies_dir = config_dir.join(".huskies"); - std::fs::create_dir_all(&huskies_dir) - .map_err(|e| format!("cannot create .huskies dir: {e}"))?; - let path = huskies_dir.join("bot.toml"); - - let content = match payload.transport.as_str() { - "slack" => { - format!( - "enabled = true\ntransport = \"slack\"\n\nslack_bot_token = {}\nslack_signing_secret = {}\nslack_channel_ids = []\n", - toml_string(payload.slack_bot_token.as_deref().unwrap_or("")), - toml_string(payload.slack_signing_secret.as_deref().unwrap_or("")), - ) - } - _ => { - // Default to matrix - format!( - "enabled = true\ntransport = \"matrix\"\n\nhomeserver = {}\nusername = {}\npassword = {}\nroom_ids = []\nallowed_users = []\n", - toml_string(payload.homeserver.as_deref().unwrap_or("")), - toml_string(payload.username.as_deref().unwrap_or("")), - toml_string(payload.password.as_deref().unwrap_or("")), - ) - } - }; - - std::fs::write(&path, content).map_err(|e| format!("cannot write bot.toml: {e}")) -} - -/// Escape a string as a TOML quoted string. -fn toml_string(s: &str) -> String { - format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) -} - -/// `GET /api/gateway/pipeline` — fetch pipeline status from all registered projects in parallel. -/// -/// Returns `{ "active": "", "projects": { "": { "counts": {...}, "blocked": [...] } | { "error": "..." } } }`. -/// Requests to each project container are issued concurrently — wall-clock latency is -/// bounded by the slowest responding project, not the sum of all response times. -#[handler] -pub async fn gateway_all_pipeline_handler(state: Data<&Arc>) -> Response { - let project_urls: BTreeMap = state - .projects - .read() - .await - .iter() - .map(|(n, e)| (n.clone(), e.url.clone())) - .collect(); - - let results = fetch_all_project_pipeline_statuses(&project_urls, &state.client).await; - - let active = state.active_project.read().await.clone(); - let body = json!({ "active": active, "projects": results }); - Response::builder() - .status(StatusCode::OK) - .header("Content-Type", "application/json") - .body(Body::from(serde_json::to_vec(&body).unwrap_or_default())) -} - -/// `GET /api/gateway/bot-config` — return current bot.toml fields as JSON. -#[handler] -pub async fn gateway_bot_config_get_handler(state: Data<&Arc>) -> Response { - let payload = read_bot_config_raw(&state.config_dir); - Response::builder() - .status(StatusCode::OK) - .header("Content-Type", "application/json") - .body(Body::from(serde_json::to_vec(&payload).unwrap_or_default())) -} - -/// `POST /api/gateway/bot-config` — write new bot.toml and restart the bot. -#[handler] -pub async fn gateway_bot_config_save_handler( - state: Data<&Arc>, - body: Json, -) -> Response { - if let Err(e) = write_bot_config(&state.config_dir, &body) { - let err = json!({ "ok": false, "error": e }); - return Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .header("Content-Type", "application/json") - .body(Body::from(serde_json::to_vec(&err).unwrap_or_default())); - } - - // Abort the existing bot task (if any) and spawn a fresh one with the new config. - { - let mut handle = state.bot_handle.lock().await; - if let Some(h) = handle.take() { - h.abort(); - } - let gateway_projects: Vec = state.projects.read().await.keys().cloned().collect(); - let gateway_project_urls: std::collections::BTreeMap = state - .projects - .read() - .await - .iter() - .map(|(name, entry)| (name.clone(), entry.url.clone())) - .collect(); - let new_handle = spawn_gateway_bot( - &state.config_dir, - Arc::clone(&state.active_project), - gateway_projects, - gateway_project_urls, - state.port, - ); - *handle = new_handle; - } - - crate::slog!("[gateway] Bot configuration saved; bot restarted"); - let ok = json!({ "ok": true }); - Response::builder() - .status(StatusCode::OK) - .header("Content-Type", "application/json") - .body(Body::from(serde_json::to_vec(&ok).unwrap_or_default())) -} - -/// Self-contained HTML page for bot configuration. -const GATEWAY_BOT_CONFIG_HTML: &str = r#" - - - - -Bot Configuration — Huskies Gateway - - - -
-
- ← Gateway - -

Bot Configuration

-
-
- - -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - - -"#; - -/// Serve the bot configuration HTML page at `GET /bot-config`. -#[handler] -pub async fn gateway_bot_config_page_handler() -> Response { - Response::builder() - .status(StatusCode::OK) - .header("Content-Type", "text/html; charset=utf-8") - .body(Body::from(GATEWAY_BOT_CONFIG_HTML)) -} - -// ── Gateway server startup ─────────────────────────────────────────── +// Re-export public types that callers reference as `crate::gateway::*`. +pub use crate::service::gateway::{ + GatewayConfig, GatewayState as GatewayStateType, JoinedAgent, ProjectEntry, + fetch_all_project_pipeline_statuses, format_aggregate_status_compact, + spawn_gateway_notification_poller, +}; /// Build the complete gateway route tree. /// @@ -2093,13 +82,12 @@ pub fn build_gateway_route(state_arc: Arc) -> impl poem::Endpoint /// 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> { - // Locate the gateway config directory (parent of `projects.toml`). let config_dir = config_path .parent() .unwrap_or(std::path::Path::new(".")) .to_path_buf(); - let config = GatewayConfig::load(config_path).map_err(std::io::Error::other)?; + let config = gateway::io::load_config(config_path).map_err(std::io::Error::other)?; let state = GatewayState::new(config, config_dir.clone(), port).map_err(std::io::Error::other)?; let state_arc = Arc::new(state); @@ -2118,9 +106,8 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> { .join(", ") ); - // 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) { + // Write `.mcp.json` so that the gateway's bot connects to this gateway's MCP endpoint. + if let Err(e) = gateway::io::write_gateway_mcp_json(&config_dir, port) { crate::slog!("[gateway] Warning: could not write .mcp.json: {e}"); } @@ -2133,7 +120,7 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> { .iter() .map(|(name, entry)| (name.clone(), entry.url.clone())) .collect(); - let bot_abort = spawn_gateway_bot( + let bot_abort = gateway::io::spawn_gateway_bot( &config_dir, Arc::clone(&state_arc.active_project), gateway_projects, @@ -2153,453 +140,14 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> { .await } -// ── 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 `/.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. -/// -/// Returns an [`tokio::task::AbortHandle`] if the bot task was spawned, `None` otherwise. -fn spawn_gateway_bot( - config_dir: &Path, - active_project: ActiveProject, - gateway_projects: Vec, - gateway_project_urls: std::collections::BTreeMap, - port: u16, -) -> Option { - 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::>(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, - gateway_project_urls, - ) -} - -// ── Cross-project notification poller ───────────────────────────────── - -/// Spawn a background task that polls `GET /api/events?since={ts_ms}` on every -/// registered project server and forwards new events to the gateway's chat rooms. -/// -/// Each event is prefixed with `[project-name]` so users can distinguish which -/// project emitted the notification. Unreachable projects produce a log warning -/// and are skipped; the poller continues with all other projects. -/// -/// This is only called when the gateway bot is enabled (`bot.toml enabled = true`). -/// -/// # Arguments -/// -/// * `transport` — the gateway-level [`ChatTransport`] used to send messages. -/// * `room_ids` — the list of room IDs to send notifications to. -/// * `project_urls` — the map of project name → base URL (e.g. `http://host:3001`). -/// * `poll_interval_secs` — how often to poll each project (default 5). -pub fn spawn_gateway_notification_poller( - transport: Arc, - room_ids: Vec, - project_urls: BTreeMap, - poll_interval_secs: u64, -) { - tokio::spawn(async move { - let client = Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build() - .unwrap_or_else(|_| Client::new()); - let interval = std::time::Duration::from_secs(poll_interval_secs.max(1)); - - // Track the last seen timestamp per project so we only receive new events. - let mut last_ts: HashMap = project_urls - .keys() - .map(|name| (name.clone(), 0u64)) - .collect(); - - loop { - for (project_name, base_url) in &project_urls { - let since = last_ts.get(project_name).copied().unwrap_or(0); - let url = format!("{base_url}/api/events?since={since}"); - - let response = match client.get(&url).send().await { - Ok(r) => r, - Err(e) => { - crate::slog!( - "[gateway-poller] {project_name}: unreachable ({e}); skipping" - ); - continue; - } - }; - - let events: Vec = match response.json().await { - Ok(v) => v, - Err(e) => { - crate::slog!( - "[gateway-poller] {project_name}: failed to parse events: {e}" - ); - continue; - } - }; - - for event in &events { - // Advance the cursor. - let ts = event.timestamp_ms(); - if ts > *last_ts.get(project_name).unwrap_or(&0) { - last_ts.insert(project_name.clone(), ts); - } - - let (plain, html) = format_gateway_event(project_name, event); - for room_id in &room_ids { - if let Err(e) = transport.send_message(room_id, &plain, &html).await { - crate::slog!( - "[gateway-poller] Failed to send notification to {room_id}: {e}" - ); - } - } - } - } - - tokio::time::sleep(interval).await; - } - }); -} - -/// Format a [`crate::http::events::StoredEvent`] from a project into a gateway notification. -/// -/// Prefixes the message with `[project-name]` so users can distinguish which -/// project emitted the event. Story names are not available at the gateway -/// level, so the item ID is used as a fallback (the formatting functions -/// extract the numeric story number from it automatically). -fn format_gateway_event( - project_name: &str, - event: &crate::http::events::StoredEvent, -) -> (String, String) { - use crate::http::events::StoredEvent; - use crate::service::notifications::{ - format_blocked_notification, format_error_notification, format_stage_notification, - stage_display_name, - }; - - let prefix = format!("[{project_name}] "); - - match event { - StoredEvent::StageTransition { - story_id, - from_stage, - to_stage, - .. - } => { - let from_display = stage_display_name(from_stage); - let to_display = stage_display_name(to_stage); - let (plain, html) = format_stage_notification(story_id, None, from_display, to_display); - (format!("{prefix}{plain}"), format!("{prefix}{html}")) - } - StoredEvent::MergeFailure { - story_id, reason, .. - } => { - let (plain, html) = format_error_notification(story_id, None, reason); - (format!("{prefix}{plain}"), format!("{prefix}{html}")) - } - StoredEvent::StoryBlocked { - story_id, reason, .. - } => { - let (plain, html) = format_blocked_notification(story_id, None, reason); - (format!("{prefix}{plain}"), format!("{prefix}{html}")) - } - } -} - -// ── Tests ──────────────────────────────────────────────────────────── +// ── 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() { - let config = GatewayConfig { - projects: BTreeMap::new(), - }; - assert!(GatewayState::new(config, PathBuf::from("."), 3000).is_err()); - } - - #[test] - fn gateway_state_sets_first_project_active() { - let mut projects = BTreeMap::new(); - projects.insert( - "alpha".into(), - ProjectEntry { - url: "http://a:3001".into(), - }, - ); - projects.insert( - "beta".into(), - ProjectEntry { - url: "http://b:3002".into(), - }, - ); - let config = GatewayConfig { projects }; - let state = GatewayState::new(config, PathBuf::from("."), 3000).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(); - let names: Vec<&str> = defs - .iter() - .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(); - projects.insert( - "alpha".into(), - ProjectEntry { - url: "http://a:3001".into(), - }, - ); - projects.insert( - "beta".into(), - ProjectEntry { - url: "http://b:3002".into(), - }, - ); - let config = GatewayConfig { projects }; - let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap(); - - let params = json!({ "arguments": { "project": "beta" } }); - let resp = handle_switch_project(¶ms, &state, None).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(); - projects.insert( - "alpha".into(), - ProjectEntry { - url: "http://a:3001".into(), - }, - ); - let config = GatewayConfig { projects }; - let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap(); - - let params = json!({ "arguments": { "project": "nonexistent" } }); - let resp = handle_switch_project(¶ms, &state, None).await; - assert!(resp.error.is_some()); - } - - #[tokio::test] - async fn active_url_returns_correct_url() { - let mut projects = BTreeMap::new(); - projects.insert( - "myproj".into(), - ProjectEntry { - url: "http://my:3001".into(), - }, - ); - let config = GatewayConfig { projects }; - let state = GatewayState::new(config, PathBuf::from("."), 3000).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"); - std::fs::write( - &path, - r#" -[projects.test] -url = "http://localhost:9999" -"#, - ) - .unwrap(); - - 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()); - } - - // ── 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" - ); - } - - // ── Agent join mechanism tests ─────────────────────────────────────── + use crate::service::gateway::{GatewayConfig, GatewayState, ProjectEntry}; + use std::collections::BTreeMap; + use std::path::PathBuf; fn make_test_state() -> Arc { let mut projects = BTreeMap::new(); @@ -2613,6 +161,17 @@ enabled = false Arc::new(GatewayState::new(config, PathBuf::new(), 3000).unwrap()) } + #[test] + fn gateway_route_tree_builds_without_panic() { + let state = make_test_state(); + let _route = build_gateway_route(state); + } + + // ── Tests that exercised internal functions have been moved to their + // ── respective service/gateway modules. The integration tests that use + // ── poem::test::TestClient and mock HTTP servers remain here since they + // ── test the combined HTTP + service interaction through real routes. + #[tokio::test] async fn generate_token_creates_pending_token() { let state = make_test_state(); @@ -2624,8 +183,8 @@ enabled = false .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(); + assert_eq!(resp.0.status(), poem::http::StatusCode::OK); + let body: serde_json::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; @@ -2636,11 +195,10 @@ enabled = false 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 { + gateway::PendingToken { created_at: chrono::Utc::now().timestamp() as f64, }, ); @@ -2656,7 +214,7 @@ enabled = false .post("/gateway/register") .header("Content-Type", "application/json") .body( - json!({ + serde_json::json!({ "token": token, "label": "test-agent", "address": "ws://localhost:3001/crdt-sync" @@ -2665,11 +223,8 @@ enabled = false ) .send() .await; - assert_eq!(resp.0.status(), StatusCode::OK); - - // Token consumed. + assert_eq!(resp.0.status(), poem::http::StatusCode::OK); 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"); @@ -2689,7 +244,7 @@ enabled = false .post("/gateway/register") .header("Content-Type", "application/json") .body( - json!({ + serde_json::json!({ "token": "bad-token", "label": "agent", "address": "ws://localhost:3001/crdt-sync" @@ -2698,28 +253,32 @@ enabled = false ) .send() .await; - assert_eq!(resp.0.status(), StatusCode::UNAUTHORIZED); + assert_eq!(resp.0.status(), poem::http::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, - last_seen: 0.0, - assigned_project: None, - }); + state + .joined_agents + .write() + .await + .push(gateway::JoinedAgent { + id: "id-1".into(), + label: "agent-1".into(), + address: "ws://a:3001/crdt-sync".into(), + registered_at: 0.0, + last_seen: 0.0, + assigned_project: None, + }); 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 = resp.0.into_body().into_json().await.unwrap(); + assert_eq!(resp.0.status(), poem::http::StatusCode::OK); + let agents: Vec = resp.0.into_body().into_json().await.unwrap(); assert_eq!(agents.len(), 1); assert_eq!(agents[0]["label"], "agent-1"); } @@ -2727,14 +286,18 @@ enabled = false #[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, - last_seen: 0.0, - assigned_project: None, - }); + state + .joined_agents + .write() + .await + .push(gateway::JoinedAgent { + id: "del-id".into(), + label: "to-delete".into(), + address: "ws://x:3001/crdt-sync".into(), + registered_at: 0.0, + last_seen: 0.0, + assigned_project: None, + }); let app = poem::Route::new() .at( "/gateway/agents/:id", @@ -2743,7 +306,7 @@ enabled = false .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_eq!(resp.0.status(), poem::http::StatusCode::NO_CONTENT); assert!(state.joined_agents.read().await.is_empty()); } @@ -2758,20 +321,24 @@ enabled = false .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); + assert_eq!(resp.0.status(), poem::http::StatusCode::NOT_FOUND); } #[tokio::test] async fn heartbeat_updates_last_seen() { let state = make_test_state(); - state.joined_agents.write().await.push(JoinedAgent { - id: "hb-id".into(), - label: "hb-agent".into(), - address: "ws://hb:3001/crdt-sync".into(), - registered_at: 0.0, - last_seen: 0.0, - assigned_project: None, - }); + state + .joined_agents + .write() + .await + .push(gateway::JoinedAgent { + id: "hb-id".into(), + label: "hb-agent".into(), + address: "ws://hb:3001/crdt-sync".into(), + registered_at: 0.0, + last_seen: 0.0, + assigned_project: None, + }); let app = poem::Route::new() .at( "/gateway/agents/:id/heartbeat", @@ -2780,7 +347,7 @@ enabled = false .data(state.clone()); let cli = poem::test::TestClient::new(app); let resp = cli.post("/gateway/agents/hb-id/heartbeat").send().await; - assert_eq!(resp.0.status(), StatusCode::NO_CONTENT); + assert_eq!(resp.0.status(), poem::http::StatusCode::NO_CONTENT); let agents = state.joined_agents.read().await; assert!(agents[0].last_seen > 0.0); } @@ -2799,15 +366,15 @@ enabled = false .post("/gateway/agents/no-such-id/heartbeat") .send() .await; - assert_eq!(resp.0.status(), StatusCode::NOT_FOUND); + assert_eq!(resp.0.status(), poem::http::StatusCode::NOT_FOUND); } - /// AC5: When one project server is unreachable the poller continues delivering - /// events from the remaining reachable projects without failing. + // ── Notification poller integration tests ──────────────────────────── + #[tokio::test] async fn gateway_notification_poller_continues_when_one_project_unreachable() { use crate::chat::{ChatTransport, MessageId}; - use crate::http::events::StoredEvent; + use crate::service::events::StoredEvent; use async_trait::async_trait; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -2849,7 +416,6 @@ enabled = false calls: Arc::clone(&calls), }); - // Start a reachable mock project server that returns one event. let event = vec![StoredEvent::StoryBlocked { story_id: "10_story_ok".to_string(), reason: "retry limit".to_string(), @@ -2876,24 +442,21 @@ enabled = false tokio::time::sleep(std::time::Duration::from_millis(10)).await; - // An unreachable URL (port 1 cannot be bound). let bad_url = "http://127.0.0.1:1".to_string(); let mut project_urls = BTreeMap::new(); project_urls.insert("good-project".to_string(), good_url); project_urls.insert("unreachable-project".to_string(), bad_url); - spawn_gateway_notification_poller( + gateway::spawn_gateway_notification_poller( transport as Arc, vec!["!room:example.org".to_string()], project_urls, 1, ); - // Wait for at least one poll cycle. tokio::time::sleep(std::time::Duration::from_millis(1500)).await; - // Events from the reachable project must still arrive. let messages = calls.lock().unwrap(); assert!( !messages.is_empty(), @@ -2906,7 +469,6 @@ enabled = false has_good, "Expected a notification from [good-project]; got: {messages:?}" ); - // Unreachable project must not produce any notifications. let has_bad = messages.iter().any(|m| m.contains("[unreachable-project]")); assert!( !has_bad, @@ -2914,18 +476,10 @@ enabled = false ); } - /// AC4: When both a per-project bot and the gateway aggregated stream are - /// configured, events go to each room exactly once. - /// - /// The gateway notification poller only sends to the gateway room IDs it is - /// given — it never forwards events to per-project rooms. Conversely, the - /// per-project notification listener subscribes to watcher broadcasts which - /// are completely separate from the HTTP-polled event buffer. This test - /// verifies the poller respects its room list (no cross-room leakage). #[tokio::test] async fn gateway_notification_poller_sends_only_to_configured_gateway_rooms() { use crate::chat::{ChatTransport, MessageId}; - use crate::http::events::StoredEvent; + use crate::service::events::StoredEvent; use async_trait::async_trait; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -2967,7 +521,6 @@ enabled = false rooms: Arc::clone(&rooms), }); - // Serve one event from a mock project server. let event = vec![StoredEvent::MergeFailure { story_id: "5_story_x".to_string(), reason: "conflict".to_string(), @@ -2995,13 +548,13 @@ enabled = false tokio::time::sleep(std::time::Duration::from_millis(10)).await; const GATEWAY_ROOM: &str = "!gateway:example.org"; + #[allow(dead_code)] const PER_PROJECT_ROOM: &str = "!project:example.org"; let mut project_urls = BTreeMap::new(); project_urls.insert("myproj".to_string(), url); - // Poller is given only the gateway room — per-project room must never receive. - spawn_gateway_notification_poller( + gateway::spawn_gateway_notification_poller( transport as Arc, vec![GATEWAY_ROOM.to_string()], project_urls, @@ -3011,7 +564,6 @@ enabled = false tokio::time::sleep(std::time::Duration::from_millis(1500)).await; let room_calls = rooms.lock().unwrap(); - // Every notification must go to the gateway room only. assert!( !room_calls.is_empty(), "Expected at least one notification; got none" @@ -3022,42 +574,25 @@ enabled = false "Notification must only go to the gateway room, not {room}" ); } - // The per-project room must never have been contacted. assert!( !room_calls.iter().any(|r| r == PER_PROJECT_ROOM), "Per-project room must not receive gateway aggregated notifications" ); } - /// Build the full gateway route tree and verify it does not panic. - /// - /// Poem panics at construction time when duplicate routes are registered. - /// This test catches any regression where a duplicate route is re-introduced - /// (e.g. the `/` vs `/*path` duplicate fixed in commit 0969fb5d). - #[test] - fn gateway_route_tree_builds_without_panic() { - let state = make_test_state(); - // build_gateway_route will panic if any route is registered more than once. - let _route = build_gateway_route(state); - } - - // ── init_project tool tests ────────────────────────────────────────── + // ── init_project integration tests ────────────────────────────────── #[tokio::test] async fn init_project_scaffolds_huskies_dir() { let dir = tempfile::tempdir().unwrap(); let state = make_test_state(); - let params = json!({ "arguments": { "path": dir.path().to_str().unwrap() } }); - let resp = handle_init_project(¶ms, &state, Some(json!(1))).await; + let result = gateway::init_project(&state, dir.path().to_str().unwrap(), None, None).await; assert!( - resp.result.is_some(), + result.is_ok(), "init_project should succeed: {:?}", - resp.error - ); - assert!( - dir.path().join(".huskies").exists(), - ".huskies/ should be created" + result.err() ); + assert!(dir.path().join(".huskies").exists()); assert!(dir.path().join(".huskies/project.toml").exists()); assert!(dir.path().join(".huskies/agents.toml").exists()); assert!(dir.path().join("script/test").exists()); @@ -3067,24 +602,15 @@ enabled = false async fn init_project_creates_wizard_state() { let dir = tempfile::tempdir().unwrap(); let state = make_test_state(); - let params = json!({ "arguments": { "path": dir.path().to_str().unwrap() } }); - handle_init_project(¶ms, &state, None).await; + gateway::init_project(&state, dir.path().to_str().unwrap(), None, None) + .await + .unwrap(); let wizard_state_path = dir.path().join(".huskies/wizard_state.json"); - assert!( - wizard_state_path.exists(), - "wizard_state.json should be created" - ); + assert!(wizard_state_path.exists()); let content = std::fs::read_to_string(&wizard_state_path).unwrap(); - let v: Value = - serde_json::from_str(&content).expect("wizard_state.json should be valid JSON"); - assert!( - v.get("steps").is_some(), - "wizard state should have a 'steps' field" - ); - assert!( - v.get("completed").is_some(), - "wizard state should have a 'completed' field" - ); + let v: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert!(v.get("steps").is_some()); + assert!(v.get("completed").is_some()); } #[tokio::test] @@ -3092,22 +618,15 @@ enabled = false let dir = tempfile::tempdir().unwrap(); std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); let state = make_test_state(); - let params = json!({ "arguments": { "path": dir.path().to_str().unwrap() } }); - let resp = handle_init_project(¶ms, &state, None).await; - assert!( - resp.error.is_some(), - "should return error for already-initialised project" - ); - let msg = &resp.error.unwrap().message; - assert!(msg.contains(".huskies/"), "error should mention .huskies/"); + let result = gateway::init_project(&state, dir.path().to_str().unwrap(), None, None).await; + assert!(result.is_err()); } #[tokio::test] async fn init_project_missing_path_returns_error() { let state = make_test_state(); - let params = json!({ "arguments": {} }); - let resp = handle_init_project(¶ms, &state, None).await; - assert!(resp.error.is_some()); + let result = gateway::init_project(&state, "", None, None).await; + assert!(result.is_err()); } #[tokio::test] @@ -3125,22 +644,17 @@ enabled = false let state = Arc::new(GatewayState::new(config, config_dir.path().to_path_buf(), 3000).unwrap()); - let params = json!({ - "arguments": { - "path": dir.path().to_str().unwrap(), - "name": "new-project", - "url": "http://new-project:3002" - } - }); - let resp = handle_init_project(¶ms, &state, Some(json!(1))).await; - assert!(resp.result.is_some(), "should succeed: {:?}", resp.error); + let result = gateway::init_project( + &state, + dir.path().to_str().unwrap(), + Some("new-project"), + Some("http://new-project:3002"), + ) + .await; + assert!(result.is_ok()); - // Project should be registered. let projects = state.projects.read().await; - assert!( - projects.contains_key("new-project"), - "new-project should be in projects map" - ); + assert!(projects.contains_key("new-project")); assert_eq!(projects["new-project"].url, "http://new-project:3002"); } @@ -3157,32 +671,24 @@ enabled = false let config = GatewayConfig { projects }; let state = Arc::new(GatewayState::new(config, PathBuf::new(), 3000).unwrap()); - let params = json!({ - "arguments": { - "path": dir.path().to_str().unwrap(), - "name": "taken", - "url": "http://new:3002" - } - }); - let resp = handle_init_project(¶ms, &state, None).await; - assert!(resp.error.is_some(), "duplicate name should return error"); + let result = gateway::init_project( + &state, + dir.path().to_str().unwrap(), + Some("taken"), + Some("http://new:3002"), + ) + .await; + assert!(result.is_err()); } - /// Integration test: call init_project then call wizard_status via the MCP - /// proxy and confirm a valid wizard state response is returned. - /// - /// A lightweight mock HTTP server is started to stand in for the project - /// container, returning a pre-canned wizard_status result. #[tokio::test] async fn init_project_then_wizard_status_integration() { use tokio::io::{AsyncReadExt, AsyncWriteExt}; - // Start a mock project MCP server on an ephemeral port. let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let mock_port = listener.local_addr().unwrap().port(); let mock_url = format!("http://127.0.0.1:{mock_port}"); - // Spawn the mock: accept one connection and return a wizard_status response. tokio::spawn(async move { if let Ok((mut stream, _)) = listener.accept().await { let mut buf = vec![0u8; 4096]; @@ -3207,10 +713,8 @@ enabled = false } }); - // Give the mock a moment to start. tokio::time::sleep(std::time::Duration::from_millis(10)).await; - // Create gateway state pointing at the mock server. let mut projects = BTreeMap::new(); projects.insert("mock-project".into(), ProjectEntry { url: mock_url }); let config = GatewayConfig { projects }; @@ -3218,210 +722,55 @@ enabled = false let state = Arc::new(GatewayState::new(config, config_dir.path().to_path_buf(), 3000).unwrap()); - // 1. Call init_project. let project_dir = tempfile::tempdir().unwrap(); - let params = json!({ - "arguments": { "path": project_dir.path().to_str().unwrap() } - }); - let resp = handle_init_project(¶ms, &state, Some(json!(1))).await; - assert!( - resp.result.is_some(), - "init_project should succeed: {:?}", - resp.error - ); + let result = + gateway::init_project(&state, project_dir.path().to_str().unwrap(), None, None).await; + assert!(result.is_ok()); + assert!(project_dir.path().join(".huskies").exists()); - // Verify scaffolding. - assert!( - project_dir.path().join(".huskies").exists(), - ".huskies/ must be created" - ); let wizard_path = project_dir.path().join(".huskies/wizard_state.json"); - assert!(wizard_path.exists(), "wizard_state.json must be created"); + assert!(wizard_path.exists()); - // 2. Call wizard_status via the MCP proxy (proxied to our mock server). - let proxy_body = serde_json::to_vec(&json!({ + // Proxy call to the mock server. + let active_url = state.active_url().await.unwrap(); + let proxy_body = serde_json::to_vec(&serde_json::json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "wizard_status", "arguments": {} } })) .unwrap(); - let proxy_resp = proxy_mcp_call(&state, &proxy_body).await; - assert!( - proxy_resp.is_ok(), - "proxy call should succeed: {:?}", - proxy_resp.err() - ); + let proxy_resp = gateway::io::proxy_mcp_call(&state.client, &active_url, &proxy_body).await; + assert!(proxy_resp.is_ok()); - // 3. Confirm the response contains wizard state data. - let resp_json: Value = serde_json::from_slice(&proxy_resp.unwrap()).unwrap(); + let resp_json: serde_json::Value = serde_json::from_slice(&proxy_resp.unwrap()).unwrap(); let result = resp_json.get("result"); - assert!(result.is_some(), "response should have a result field"); + assert!(result.is_some()); let text = result .and_then(|r| r.get("content")) .and_then(|c| c.get(0)) .and_then(|c| c.get("text")) .and_then(|t| t.as_str()) .unwrap_or(""); - let wizard: Value = - serde_json::from_str(text).expect("text should be valid wizard state JSON"); - assert!( - wizard.get("steps").is_some(), - "wizard state should have a 'steps' field" - ); + let wizard: serde_json::Value = serde_json::from_str(text).unwrap(); + assert!(wizard.get("steps").is_some()); } - #[test] - fn gateway_tool_definitions_includes_init_project() { - let defs = gateway_tool_definitions(); - let names: Vec<&str> = defs - .iter() - .filter_map(|d| d.get("name").and_then(|n| n.as_str())) - .collect(); - assert!( - names.contains(&"init_project"), - "init_project should be in gateway tool definitions" - ); - } + // ── Aggregate pipeline status integration tests ───────────────────── - #[test] - fn gateway_tool_definitions_includes_aggregate_pipeline_status() { - let defs = gateway_tool_definitions(); - let names: Vec<&str> = defs - .iter() - .filter_map(|d| d.get("name").and_then(|n| n.as_str())) - .collect(); - assert!( - names.contains(&"aggregate_pipeline_status"), - "aggregate_pipeline_status should be in gateway tool definitions" - ); - } - - // ── aggregate_pipeline_counts unit tests ───────────────────────────────── - - #[test] - fn aggregate_pipeline_counts_empty_pipeline() { - let pipeline = json!({ "active": [], "backlog": [], "backlog_count": 0 }); - let result = aggregate_pipeline_counts(&pipeline); - assert_eq!(result["counts"]["backlog"], 0); - assert_eq!(result["counts"]["current"], 0); - assert_eq!(result["counts"]["qa"], 0); - assert_eq!(result["counts"]["merge"], 0); - assert_eq!(result["counts"]["done"], 0); - assert_eq!(result["blocked"].as_array().unwrap().len(), 0); - } - - #[test] - fn aggregate_pipeline_counts_stage_counts_correct() { - let pipeline = json!({ - "active": [ - { "story_id": "1_story_a", "name": "A", "stage": "current" }, - { "story_id": "2_story_b", "name": "B", "stage": "current" }, - { "story_id": "3_story_c", "name": "C", "stage": "qa" }, - { "story_id": "4_story_d", "name": "D", "stage": "done" }, - ], - "backlog": [{ "story_id": "5_story_e", "name": "E" }, { "story_id": "6_story_f", "name": "F" }], - "backlog_count": 2 - }); - let result = aggregate_pipeline_counts(&pipeline); - assert_eq!(result["counts"]["backlog"], 2); - assert_eq!(result["counts"]["current"], 2); - assert_eq!(result["counts"]["qa"], 1); - assert_eq!(result["counts"]["merge"], 0); - assert_eq!(result["counts"]["done"], 1); - assert_eq!(result["blocked"].as_array().unwrap().len(), 0); - } - - #[test] - fn aggregate_pipeline_counts_blocked_items_captured() { - let pipeline = json!({ - "active": [ - { "story_id": "10_story_blocked", "name": "Blocked", "stage": "current", "blocked": true, "retry_count": 3 }, - { "story_id": "20_story_ok", "name": "OK", "stage": "qa" }, - ], - "backlog": [], - "backlog_count": 0 - }); - let result = aggregate_pipeline_counts(&pipeline); - let blocked = result["blocked"].as_array().unwrap(); - assert_eq!(blocked.len(), 1); - assert_eq!(blocked[0]["story_id"], "10_story_blocked"); - assert_eq!(blocked[0]["stage"], "current"); - assert!( - blocked[0]["reason"] - .as_str() - .unwrap() - .contains("blocked after 3 retries"), - "reason: {}", - blocked[0]["reason"] - ); - } - - #[test] - fn format_aggregate_status_compact_healthy_project() { - let mut statuses = BTreeMap::new(); - statuses.insert( - "huskies".to_string(), - json!({ - "counts": { "backlog": 5, "current": 2, "qa": 1, "merge": 0, "done": 12 }, - "blocked": [] - }), - ); - let output = format_aggregate_status_compact(&statuses); - assert!(output.contains("huskies"), "output: {output}"); - assert!(output.contains("B:5"), "output: {output}"); - assert!(output.contains("C:2"), "output: {output}"); - assert!(output.contains("Q:1"), "output: {output}"); - assert!(output.contains("D:12"), "output: {output}"); - assert!(!output.contains("blocked:"), "output: {output}"); - } - - #[test] - fn format_aggregate_status_compact_unreachable_project() { - let mut statuses = BTreeMap::new(); - statuses.insert( - "broken".to_string(), - json!({ "error": "connection refused" }), - ); - let output = format_aggregate_status_compact(&statuses); - assert!(output.contains("broken"), "output: {output}"); - assert!(output.contains("UNREACHABLE"), "output: {output}"); - assert!(output.contains("connection refused"), "output: {output}"); - } - - #[test] - fn format_aggregate_status_compact_blocked_items_shown() { - let mut statuses = BTreeMap::new(); - statuses.insert( - "myproj".to_string(), - json!({ - "counts": { "backlog": 0, "current": 1, "qa": 0, "merge": 0, "done": 0 }, - "blocked": [{ "story_id": "42_story_x", "name": "X", "stage": "current", "reason": "blocked after 3 retries" }] - }), - ); - let output = format_aggregate_status_compact(&statuses); - assert!(output.contains("blocked:"), "output: {output}"); - assert!(output.contains("42_story_x"), "output: {output}"); - } - - /// Integration test: two mock projects (one healthy, one unreachable). - /// Asserts that `fetch_all_project_pipeline_statuses` reports both correctly. #[tokio::test] async fn aggregate_pipeline_status_integration_healthy_and_unreachable() { use tokio::io::{AsyncReadExt, AsyncWriteExt}; - // Start a mock project MCP server that returns a get_pipeline_status response. let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let mock_port = listener.local_addr().unwrap().port(); let healthy_url = format!("http://127.0.0.1:{mock_port}"); - // The mock responds to exactly 1 connection then stops. tokio::spawn(async move { if let Ok((mut stream, _)) = listener.accept().await { let mut buf = vec![0u8; 4096]; let _ = stream.read(&mut buf).await; - // Return a pipeline status with items at multiple stages and one blocked item. - let pipeline_json = serde_json::to_string(&json!({ + let pipeline_json = serde_json::to_string(&serde_json::json!({ "active": [ { "story_id": "1_story_a", "name": "A", "stage": "current" }, { "story_id": "2_story_b", "name": "B", "stage": "qa" }, @@ -3429,8 +778,9 @@ enabled = false ], "backlog": [{ "story_id": "4_story_d", "name": "D" }], "backlog_count": 1 - })).unwrap(); - let body = serde_json::to_vec(&json!({ + })) + .unwrap(); + let body = serde_json::to_vec(&serde_json::json!({ "jsonrpc": "2.0", "id": 1, "result": { @@ -3447,109 +797,43 @@ enabled = false } }); - // Give the mock a moment to bind. tokio::time::sleep(std::time::Duration::from_millis(10)).await; - // Second "project" points to an unreachable port. - let unreachable_url = "http://127.0.0.1:1".to_string(); // port 1 is not bindable + let unreachable_url = "http://127.0.0.1:1".to_string(); let mut project_urls = BTreeMap::new(); project_urls.insert("healthy-project".to_string(), healthy_url); project_urls.insert("broken-project".to_string(), unreachable_url); - let client = Client::new(); - let statuses = fetch_all_project_pipeline_statuses(&project_urls, &client).await; + let client = reqwest::Client::new(); + let statuses = gateway::fetch_all_project_pipeline_statuses(&project_urls, &client).await; - // Both projects should be present in the result. - assert!( - statuses.contains_key("healthy-project"), - "healthy-project must be in response" - ); - assert!( - statuses.contains_key("broken-project"), - "broken-project must be in response" - ); + assert!(statuses.contains_key("healthy-project")); + assert!(statuses.contains_key("broken-project")); - // Healthy project should have correct counts. let healthy = &statuses["healthy-project"]; - assert!( - healthy.get("error").is_none(), - "healthy project should not have error: {healthy}" - ); + assert!(healthy.get("error").is_none()); assert_eq!(healthy["counts"]["backlog"], 1); assert_eq!(healthy["counts"]["current"], 2); assert_eq!(healthy["counts"]["qa"], 1); - // Healthy project should report the blocked item. let blocked = healthy["blocked"].as_array().unwrap(); - assert_eq!(blocked.len(), 1, "expected 1 blocked item: {blocked:?}"); + assert_eq!(blocked.len(), 1); assert_eq!(blocked[0]["story_id"], "3_story_c"); - // Unreachable project should have an error field. let broken = &statuses["broken-project"]; - assert!( - broken.get("error").is_some(), - "unreachable project must have error field: {broken}" - ); + assert!(broken.get("error").is_some()); } - // ── format_gateway_event unit tests ───────────────────────────────── + // ── Multi-project notification poller integration ──────────────────── - #[test] - fn format_gateway_event_stage_transition_prefixes_project_name() { - use crate::http::events::StoredEvent; - - let event = StoredEvent::StageTransition { - story_id: "42_story_my_feature".to_string(), - from_stage: "2_current".to_string(), - to_stage: "3_qa".to_string(), - timestamp_ms: 1000, - }; - let (plain, html) = format_gateway_event("huskies", &event); - assert!(plain.starts_with("[huskies] "), "plain: {plain}"); - assert!(html.starts_with("[huskies] "), "html: {html}"); - assert!(plain.contains("Current"), "plain: {plain}"); - assert!(plain.contains("QA"), "plain: {plain}"); - } - - #[test] - fn format_gateway_event_merge_failure_prefixes_project_name() { - use crate::http::events::StoredEvent; - - let event = StoredEvent::MergeFailure { - story_id: "42_story_my_feature".to_string(), - reason: "merge conflict".to_string(), - timestamp_ms: 1000, - }; - let (plain, _html) = format_gateway_event("robot-studio", &event); - assert!(plain.starts_with("[robot-studio] "), "plain: {plain}"); - assert!(plain.contains("merge conflict"), "plain: {plain}"); - } - - #[test] - fn format_gateway_event_story_blocked_prefixes_project_name() { - use crate::http::events::StoredEvent; - - let event = StoredEvent::StoryBlocked { - story_id: "43_story_bar".to_string(), - reason: "retry limit exceeded".to_string(), - timestamp_ms: 2000, - }; - let (plain, _html) = format_gateway_event("huskies", &event); - assert!(plain.starts_with("[huskies] "), "plain: {plain}"); - assert!(plain.contains("BLOCKED"), "plain: {plain}"); - } - - /// AC7 integration test: two mock HTTP servers, trigger events, assert - /// aggregated stream gets both with project tags. #[tokio::test] async fn gateway_notification_poller_delivers_events_from_two_projects_with_project_tags() { use crate::chat::{ChatTransport, MessageId}; - use crate::http::events::StoredEvent; + use crate::service::events::StoredEvent; use async_trait::async_trait; use tokio::io::{AsyncReadExt, AsyncWriteExt}; - // ── MockTransport ────────────────────────────────────────────────── type CallLog = Arc>>; struct MockTransport { @@ -3592,7 +876,6 @@ enabled = false calls: Arc::clone(&calls), }); - // ── Mock HTTP server for project "alpha" ─────────────────────────── let alpha_events = vec![StoredEvent::StageTransition { story_id: "1_story_alpha".to_string(), from_stage: "2_current".to_string(), @@ -3604,7 +887,6 @@ enabled = false let alpha_port = alpha_listener.local_addr().unwrap().port(); let alpha_url = format!("http://127.0.0.1:{alpha_port}"); tokio::spawn(async move { - // Handle two requests (poller might poll more than once). for _ in 0..4 { if let Ok((mut stream, _)) = alpha_listener.accept().await { let mut buf = vec![0u8; 4096]; @@ -3619,7 +901,6 @@ enabled = false } }); - // ── Mock HTTP server for project "beta" ──────────────────────────── let beta_events = vec![StoredEvent::MergeFailure { story_id: "2_story_beta".to_string(), reason: "merge conflict in lib.rs".to_string(), @@ -3644,25 +925,21 @@ enabled = false } }); - // Give mock servers a moment to bind. tokio::time::sleep(std::time::Duration::from_millis(10)).await; - // ── Run the poller ───────────────────────────────────────────────── let mut project_urls = BTreeMap::new(); project_urls.insert("alpha".to_string(), alpha_url); project_urls.insert("beta".to_string(), beta_url); - spawn_gateway_notification_poller( + gateway::spawn_gateway_notification_poller( transport as Arc, vec!["!room:example.org".to_string()], project_urls, - 1, // poll every 1 second + 1, ); - // Wait long enough for at least one poll cycle. tokio::time::sleep(std::time::Duration::from_millis(1500)).await; - // ── Assert both events appear with correct [project-name] prefix ─── let calls = calls.lock().unwrap(); assert!( !calls.is_empty(), @@ -3687,7 +964,6 @@ enabled = false "Expected a notification from [beta] containing 'merge conflict'; got: {plains:?}" ); - // All notifications must go to the configured room. for (room_id, _, _) in calls.iter() { assert_eq!( room_id, "!room:example.org", @@ -3695,4 +971,64 @@ enabled = false ); } } + + // ── BotConfig tests ───────────────────────────────────────────────── + + #[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(); + + let config = BotConfig::load(tmp.path()); + assert!(config.is_some()); + 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; + let tmp = tempfile::tempdir().unwrap(); + let config = BotConfig::load(tmp.path()); + assert!(config.is_none()); + } + + #[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()); + } } diff --git a/server/src/http/events.rs b/server/src/http/events.rs index 5b0e3730..2203c41d 100644 --- a/server/src/http/events.rs +++ b/server/src/http/events.rs @@ -8,7 +8,9 @@ //! Domain logic lives in `service::events`; this module is a thin HTTP //! adapter: extract query params → call service → shape response. -pub use crate::service::events::{EventBuffer, StoredEvent, subscribe_to_watcher}; +#[cfg(test)] +pub use crate::service::events::StoredEvent; +pub use crate::service::events::{EventBuffer, subscribe_to_watcher}; // MAX_BUFFER_SIZE is used in tests via `use super::*`. #[cfg(test)] pub use crate::service::events::MAX_BUFFER_SIZE; diff --git a/server/src/http/gateway.rs b/server/src/http/gateway.rs new file mode 100644 index 00000000..6b399897 --- /dev/null +++ b/server/src/http/gateway.rs @@ -0,0 +1,1072 @@ +//! Gateway HTTP handlers — thin transport shells for the gateway service. +//! +//! Each handler calls `service::gateway::*` for business logic and formats +//! the response. No inline business logic, no `reqwest`, no filesystem access. + +use crate::service::gateway::{self, GatewayState}; +use poem::handler; +use poem::http::StatusCode; +use poem::web::Path as PoemPath; +use poem::web::{Data, Json}; +use poem::{Body, Request, Response}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::collections::BTreeMap; +use std::sync::Arc; + +// ── JSON-RPC types ────────────────────────────────────────────────────────── + +/// JSON-RPC request. +#[derive(Deserialize)] +struct JsonRpcRequest { + jsonrpc: String, + id: Option, + method: String, + #[serde(default)] + params: Value, +} + +/// JSON-RPC response. +#[derive(Serialize)] +pub(crate) struct JsonRpcResponse { + jsonrpc: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) error: Option, +} + +#[derive(Debug, Serialize)] +pub(crate) struct JsonRpcError { + code: i64, + pub(crate) message: String, +} + +impl JsonRpcResponse { + pub(crate) fn success(id: Option, result: Value) -> Self { + Self { + jsonrpc: "2.0", + id, + result: Some(result), + error: None, + } + } + + pub(crate) fn error(id: Option, code: i64, message: String) -> Self { + Self { + jsonrpc: "2.0", + id, + result: None, + error: Some(JsonRpcError { code, message }), + } + } +} + +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)) +} + +// ── MCP tool definitions ─────────────────────────────────────────���────────── + +/// Gateway-specific MCP tools exposed alongside the proxied tools. +const GATEWAY_TOOLS: &[&str] = &[ + "switch_project", + "gateway_status", + "gateway_health", + "init_project", + "aggregate_pipeline_status", +]; + +/// Gateway tool definitions. +pub(crate) fn gateway_tool_definitions() -> Vec { + 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": {} + } + }), + json!({ + "name": "init_project", + "description": "Initialize a new huskies project at the given path by scaffolding .huskies/ and related files — the same as running `huskies init `. Prefer this tool over asking the user to run the CLI. If `name` and `url` are supplied the project is also registered in projects.toml so switch_project can reach it immediately.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Absolute filesystem path to the project directory to initialise. The directory is created if it does not exist." + }, + "name": { + "type": "string", + "description": "Optional: short name to register the project under in projects.toml (e.g. 'my-app'). Requires `url`." + }, + "url": { + "type": "string", + "description": "Optional: base URL of the huskies container that will serve this project (e.g. 'http://my-app:3001'). Required when `name` is given." + } + }, + "required": ["path"] + } + }), + json!({ + "name": "aggregate_pipeline_status", + "description": "Fetch pipeline status from ALL registered projects in parallel and return an aggregated report. For each project: stage counts (backlog/current/qa/merge/done) and a list of blocked or failing items with triage detail. Unreachable projects are included with an error state rather than failing the whole call.", + "inputSchema": { + "type": "object", + "properties": {} + } + }), + ] +} + +// ── MCP POST handler ──────────────────────────────────────────────────────── + +/// 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>, +) -> 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( + None, + -32700, + "Unsupported Content-Type; expected application/json".into(), + )); + } + + let bytes = match body.into_bytes().await { + Ok(b) => b, + Err(_) => { + return to_json_response(JsonRpcResponse::error(None, -32700, "Parse error".into())); + } + }; + + let rpc: JsonRpcRequest = match serde_json::from_slice(&bytes) { + Ok(r) => r, + Err(_) => { + return to_json_response(JsonRpcResponse::error(None, -32700, "Parse error".into())); + } + }; + + if rpc.jsonrpc != "2.0" { + return to_json_response(JsonRpcResponse::error( + rpc.id, + -32600, + "Invalid JSON-RPC version".into(), + )); + } + + 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" => match handle_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" => { + let tool_name = rpc + .params + .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, rpc.id.clone()).await, + ) + } else { + proxy_and_respond(&state, &bytes, rpc.id).await + } + } + _ => proxy_and_respond(&state, &bytes, rpc.id).await, + } +} + +/// Proxy a request to the active project and format the response. +async fn proxy_and_respond(state: &GatewayState, bytes: &[u8], id: Option) -> Response { + let url = match state.active_url().await { + Ok(u) => u, + Err(e) => { + return to_json_response(JsonRpcResponse::error(id, -32603, e.to_string())); + } + }; + match gateway::io::proxy_mcp_call(&state.client, &url, 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( + id, + -32603, + format!("proxy error: {e}"), + )), + } +} + +/// GET handler — method not allowed. +#[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) -> JsonRpcResponse { + JsonRpcResponse::success( + id, + json!({ + "protocolVersion": "2025-03-26", + "capabilities": { "tools": {} }, + "serverInfo": { + "name": "huskies-gateway", + "version": "1.0.0" + } + }), + ) +} + +/// Fetch tools/list from the active project and merge in gateway tools. +async fn handle_tools_list( + state: &GatewayState, + id: Option, +) -> Result { + let url = state.active_url().await.map_err(|e| e.to_string())?; + + let resp_json = gateway::io::fetch_tools_list(&state.client, &url).await?; + + let mut tools: Vec = resp_json + .get("result") + .and_then(|r| r.get("tools")) + .and_then(|t| t.as_array()) + .cloned() + .unwrap_or_default(); + + let mut all_tools = gateway_tool_definitions(); + all_tools.append(&mut tools); + + Ok(JsonRpcResponse::success(id, json!({ "tools": all_tools }))) +} + +// ── Gateway tool dispatch ──────────────────────────────────────────────��──── + +/// Dispatch a gateway-specific tool call. +async fn handle_gateway_tool( + tool_name: &str, + params: &Value, + state: &GatewayState, + id: Option, +) -> JsonRpcResponse { + match tool_name { + "switch_project" => handle_switch_project_tool(params, state, id).await, + "gateway_status" => handle_gateway_status_tool(state, id).await, + "gateway_health" => handle_gateway_health_tool(state, id).await, + "init_project" => handle_init_project_tool(params, state, id).await, + "aggregate_pipeline_status" => handle_aggregate_pipeline_status_tool(state, id).await, + _ => JsonRpcResponse::error(id, -32601, format!("Unknown gateway tool: {tool_name}")), + } +} + +async fn handle_switch_project_tool( + params: &Value, + state: &GatewayState, + id: Option, +) -> 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(""); + + match gateway::switch_project(state, project).await { + Ok(url) => JsonRpcResponse::success( + id, + json!({ + "content": [{ + "type": "text", + "text": format!("Switched to project '{project}' ({url})") + }] + }), + ), + Err(e) => JsonRpcResponse::error(id, -32602, e.to_string()), + } +} + +async fn handle_gateway_status_tool(state: &GatewayState, id: Option) -> JsonRpcResponse { + let active = state.active_project.read().await.clone(); + let url = match state.active_url().await { + Ok(u) => u, + Err(e) => return JsonRpcResponse::error(id.clone(), -32603, e.to_string()), + }; + + match gateway::io::fetch_pipeline_status_for_project(&state.client, &url).await { + Ok(upstream) => { + let pipeline = upstream.get("result").cloned().unwrap_or(json!(null)); + JsonRpcResponse::success( + id, + json!({ + "content": [{ + "type": "text", + "text": format!( + "Pipeline status for '{active}':\n{}", + serde_json::to_string_pretty(&pipeline).unwrap_or_default() + ) + }] + }), + ) + } + Err(e) => JsonRpcResponse::error(id, -32603, e), + } +} + +async fn handle_gateway_health_tool(state: &GatewayState, id: Option) -> JsonRpcResponse { + let mut results = BTreeMap::new(); + + let project_entries: Vec<(String, String)> = state + .projects + .read() + .await + .iter() + .map(|(n, e)| (n.clone(), e.url.clone())) + .collect(); + for (name, url) in &project_entries { + let status = match gateway::io::check_project_health(&state.client, url).await { + Ok(true) => "healthy".to_string(), + Ok(false) => "unhealthy".to_string(), + Err(e) => e, + }; + results.insert(name.clone(), status); + } + + let active = state.active_project.read().await.clone(); + JsonRpcResponse::success( + id, + json!({ + "content": [{ + "type": "text", + "text": format!( + "Health check (active: '{active}'):\n{}", + results.iter() + .map(|(name, status)| format!(" {name}: {status}")) + .collect::>() + .join("\n") + ) + }] + }), + ) +} + +async fn handle_init_project_tool( + params: &Value, + state: &GatewayState, + id: Option, +) -> JsonRpcResponse { + let args = params.get("arguments").unwrap_or(params); + + let path_str = args.get("path").and_then(|v| v.as_str()).unwrap_or(""); + let name = args.get("name").and_then(|v| v.as_str()); + let url = args.get("url").and_then(|v| v.as_str()); + + match gateway::init_project(state, path_str, name, url).await { + Ok(registered_name) => { + let next_steps = if let Some(ref n) = registered_name { + format!( + "Project registered as '{n}' in projects.toml.\n\ + Next steps:\n\ + 1. Start a huskies server at '{path_str}' \ + (e.g. `huskies {path_str}` or via Docker).\n\ + 2. Call switch_project with name='{n}' to make it active.\n\ + 3. Call wizard_status to begin the setup wizard." + ) + } else { + format!( + "Next steps:\n\ + 1. Start a huskies server at '{path_str}' \ + (e.g. `huskies {path_str}` or via Docker).\n\ + 2. Register the project: call init_project again with name and url \ + parameters, or add it to projects.toml manually.\n\ + 3. Call switch_project and then wizard_status to begin the setup wizard.\n\n\ + Note: wizard_* MCP tools require a running huskies server for the project." + ) + }; + + JsonRpcResponse::success( + id, + json!({ + "content": [{ + "type": "text", + "text": format!("Successfully initialised huskies project at '{path_str}'.\n\n{next_steps}") + }] + }), + ) + } + Err(e) => { + let code = match &e { + gateway::Error::Config(_) => -32602, + gateway::Error::DuplicateToken(_) => -32602, + _ => -32603, + }; + JsonRpcResponse::error(id, code, e.to_string()) + } + } +} + +async fn handle_aggregate_pipeline_status_tool( + state: &GatewayState, + id: Option, +) -> JsonRpcResponse { + let project_urls: BTreeMap = state + .projects + .read() + .await + .iter() + .map(|(name, entry)| (name.clone(), entry.url.clone())) + .collect(); + + let statuses = + gateway::io::fetch_all_project_pipeline_statuses(&project_urls, &state.client).await; + let active = state.active_project.read().await.clone(); + + JsonRpcResponse::success( + id, + json!({ + "content": [{ + "type": "text", + "text": format!( + "Aggregate pipeline status (active: '{active}'):\n{}", + serde_json::to_string_pretty(&statuses).unwrap_or_default() + ) + }], + "projects": statuses, + "active": active, + }), + ) +} + +// ── Agent REST handlers ───────────────────────────────────────────────────── + +/// `GET /gateway/mode` — returns `{"mode":"gateway"}`. +#[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. +#[handler] +pub async fn gateway_generate_token_handler(state: Data<&Arc>) -> Response { + let token = gateway::generate_join_token(&state).await; + 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())) +} + +/// Request body sent by a build agent when registering with the gateway. +#[derive(Deserialize)] +struct RegisterAgentRequest { + token: String, + label: String, + address: String, +} + +/// `POST /gateway/register` — build agent presents its join token and registers. +#[handler] +pub async fn gateway_register_agent_handler( + body: Body, + state: Data<&Arc>, +) -> 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")); + } + }; + + match gateway::register_agent(&state, &req.token, req.label, req.address).await { + Ok(agent) => { + let body = serde_json::to_vec(&agent).unwrap_or_default(); + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .body(Body::from(body)) + } + Err(_) => Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body(Body::from("invalid or already-used join token")), + } +} + +/// `GET /gateway/agents` — list all registered build agents. +#[handler] +pub async fn gateway_list_agents_handler(state: Data<&Arc>) -> 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, + state: Data<&Arc>, +) -> Response { + if gateway::remove_agent(&state, &id).await { + Response::builder() + .status(StatusCode::NO_CONTENT) + .body(Body::empty()) + } else { + Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::from("agent not found")) + } +} + +/// Request body for assigning an agent to a project. +#[derive(Deserialize)] +struct AssignAgentRequest { + project: Option, +} + +/// `POST /gateway/agents/:id/assign` — assign or unassign an agent to a project. +#[handler] +pub async fn gateway_assign_agent_handler( + PoemPath(id): PoemPath, + body: Json, + state: Data<&Arc>, +) -> Response { + match gateway::assign_agent(&state, &id, body.0.project).await { + Ok(agent) => { + let body = serde_json::to_vec(&agent).unwrap_or_default(); + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .body(Body::from(body)) + } + Err(gateway::Error::ProjectNotFound(msg)) => Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::from(msg)), + Err(_) => Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::from("agent not found")), + } +} + +/// `POST /gateway/agents/:id/heartbeat` — update an agent's last-seen timestamp. +#[handler] +pub async fn gateway_heartbeat_handler( + PoemPath(id): PoemPath, + state: Data<&Arc>, +) -> Response { + if gateway::heartbeat_agent(&state, &id).await { + Response::builder() + .status(StatusCode::NO_CONTENT) + .body(Body::empty()) + } else { + Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::from("agent not found")) + } +} + +// ── Health handler ────────────────────────────────────────────────────────── + +/// HTTP GET `/health` handler for the gateway. +#[handler] +pub async fn gateway_health_handler(state: Data<&Arc>) -> Response { + let (all_healthy, statuses) = gateway::health_check_all(&state).await; + + let body = json!({ + "status": if all_healthy { "ok" } else { "degraded" }, + "projects": statuses, + }); + + let status = if all_healthy { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + }; + Response::builder() + .status(status) + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_vec(&body).unwrap_or_default())) +} + +// ── Gateway Web UI ────────────────────────────────────────────────────────── + +/// `GET /api/gateway` — returns the list of registered projects and the active project. +#[handler] +pub async fn gateway_api_handler(state: Data<&Arc>) -> Response { + let active = state.active_project.read().await.clone(); + let projects: Vec = state + .projects + .read() + .await + .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. +#[handler] +pub async fn gateway_switch_handler( + state: Data<&Arc>, + body: Json, +) -> Response { + match gateway::switch_project(&state, &body.project).await { + Ok(_) => { + let body_val = json!({ "ok": true }); + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .body(Body::from( + serde_json::to_vec(&body_val).unwrap_or_default(), + )) + } + Err(e) => { + let body_val = json!({ "ok": false, "error": e.to_string() }); + Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("Content-Type", "application/json") + .body(Body::from( + serde_json::to_vec(&body_val).unwrap_or_default(), + )) + } + } +} + +// ── Project management API ────────────────────────────────────────────────── + +/// Request body for adding a new project. +#[derive(Deserialize)] +struct AddProjectRequest { + name: String, + url: String, +} + +/// `POST /api/gateway/projects` — add a new project. +#[handler] +pub async fn gateway_add_project_handler( + state: Data<&Arc>, + body: Json, +) -> Response { + match gateway::add_project(&state, &body.name, &body.url).await { + Ok(()) => { + let name = body.0.name.trim().to_string(); + let url = body.0.url.trim().to_string(); + let body_val = json!({ "name": name, "url": url }); + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .body(Body::from( + serde_json::to_vec(&body_val).unwrap_or_default(), + )) + } + Err(gateway::Error::DuplicateToken(_)) => Response::builder() + .status(StatusCode::CONFLICT) + .body(Body::from(format!( + "project '{}' already exists", + body.0.name.trim() + ))), + Err(e) => Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::from(e.to_string())), + } +} + +/// `DELETE /api/gateway/projects/:name` — remove a project. +#[handler] +pub async fn gateway_remove_project_handler( + PoemPath(name): PoemPath, + state: Data<&Arc>, +) -> Response { + match gateway::remove_project(&state, &name).await { + Ok(()) => Response::builder() + .status(StatusCode::NO_CONTENT) + .body(Body::empty()), + Err(gateway::Error::ProjectNotFound(msg)) => Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::from(msg)), + Err(e) => Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::from(e.to_string())), + } +} + +// ── Bot configuration API ─────────────────────────────────────────────────── + +/// Request/response body for the bot configuration API. +#[derive(Deserialize, Serialize, Default)] +pub(crate) struct BotConfigPayload { + transport: String, + homeserver: Option, + username: Option, + password: Option, + slack_bot_token: Option, + slack_signing_secret: Option, +} + +/// `GET /api/gateway/bot-config` — return current bot.toml fields as JSON. +#[handler] +pub async fn gateway_bot_config_get_handler(state: Data<&Arc>) -> Response { + let fields = gateway::io::read_bot_config_raw(&state.config_dir); + let payload = BotConfigPayload { + transport: fields.transport, + homeserver: fields.homeserver, + username: fields.username, + password: fields.password, + slack_bot_token: fields.slack_bot_token, + slack_signing_secret: fields.slack_signing_secret, + }; + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_vec(&payload).unwrap_or_default())) +} + +/// `POST /api/gateway/bot-config` — write new bot.toml and restart the bot. +#[handler] +pub async fn gateway_bot_config_save_handler( + state: Data<&Arc>, + body: Json, +) -> Response { + let content = gateway::config::serialize_bot_config( + &body.transport, + body.homeserver.as_deref(), + body.username.as_deref(), + body.password.as_deref(), + body.slack_bot_token.as_deref(), + body.slack_signing_secret.as_deref(), + ); + + match gateway::save_bot_config_and_restart(&state, &content).await { + Ok(()) => { + let ok = json!({ "ok": true }); + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_vec(&ok).unwrap_or_default())) + } + Err(e) => { + let err = json!({ "ok": false, "error": e.to_string() }); + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_vec(&err).unwrap_or_default())) + } + } +} + +/// `GET /api/gateway/pipeline` — fetch pipeline status from all registered projects. +#[handler] +pub async fn gateway_all_pipeline_handler(state: Data<&Arc>) -> Response { + let project_urls: BTreeMap = state + .projects + .read() + .await + .iter() + .map(|(n, e)| (n.clone(), e.url.clone())) + .collect(); + + let results = + gateway::io::fetch_all_project_pipeline_statuses(&project_urls, &state.client).await; + + let active = state.active_project.read().await.clone(); + let body = json!({ "active": active, "projects": results }); + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_vec(&body).unwrap_or_default())) +} + +// ── Bot config page ───────────────────────────────────────────────────────── + +/// Self-contained HTML page for bot configuration. +const GATEWAY_BOT_CONFIG_HTML: &str = r#" + + + + +Bot Configuration — Huskies Gateway + + + +
+
+ ← Gateway + +

Bot Configuration

+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +"#; + +/// Serve the bot configuration HTML page at `GET /bot-config`. +#[handler] +pub async fn gateway_bot_config_page_handler() -> Response { + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "text/html; charset=utf-8") + .body(Body::from(GATEWAY_BOT_CONFIG_HTML)) +} diff --git a/server/src/http/mod.rs b/server/src/http/mod.rs index 24551da0..565fa7d4 100644 --- a/server/src/http/mod.rs +++ b/server/src/http/mod.rs @@ -18,6 +18,7 @@ pub mod settings; pub(crate) mod test_helpers; pub mod workflow; +pub mod gateway; pub mod project; pub mod wizard; pub mod ws; diff --git a/server/src/service/gateway/aggregation.rs b/server/src/service/gateway/aggregation.rs new file mode 100644 index 00000000..d55478a9 --- /dev/null +++ b/server/src/service/gateway/aggregation.rs @@ -0,0 +1,136 @@ +//! Gateway aggregation — pure functions for cross-project pipeline status. +//! +//! Formats aggregated pipeline data into compact text suitable for chat +//! transports (Matrix, Slack). Uses `service::pipeline::aggregate_pipeline_counts` +//! for per-project parsing. + +use serde_json::Value; +use std::collections::BTreeMap; + +/// Format an aggregated status map as a compact, one-line-per-project string +/// suitable for Matrix/Slack messages. +/// +/// Healthy projects: `🟢 **name** — B:5 C:2 Q:1 M:0 D:12` +/// Blocked items appended on the same line: `| blocked: 42 [story]` +/// Unreachable projects: `🔴 **name** — UNREACHABLE` +pub fn format_aggregate_status_compact(statuses: &BTreeMap) -> String { + let mut lines: Vec = Vec::new(); + for (name, status) in statuses { + if let Some(err) = status.get("error").and_then(|e| e.as_str()) { + lines.push(format!("\u{1F534} **{name}** — UNREACHABLE: {err}")); + } else { + let counts = status.get("counts"); + let b = counts + .and_then(|c| c.get("backlog")) + .and_then(|n| n.as_u64()) + .unwrap_or(0); + let c = counts + .and_then(|c| c.get("current")) + .and_then(|n| n.as_u64()) + .unwrap_or(0); + let q = counts + .and_then(|c| c.get("qa")) + .and_then(|n| n.as_u64()) + .unwrap_or(0); + let m = counts + .and_then(|c| c.get("merge")) + .and_then(|n| n.as_u64()) + .unwrap_or(0); + let d = counts + .and_then(|c| c.get("done")) + .and_then(|n| n.as_u64()) + .unwrap_or(0); + + let blocked_arr = status + .get("blocked") + .and_then(|a| a.as_array()) + .cloned() + .unwrap_or_default(); + + let indicator = if blocked_arr.is_empty() { + "\u{1F7E2}" // 🟢 + } else { + "\u{1F7E0}" // 🟠 + }; + + let mut line = format!("{indicator} **{name}** — B:{b} C:{c} Q:{q} M:{m} D:{d}"); + + if !blocked_arr.is_empty() { + let ids: Vec = blocked_arr + .iter() + .filter_map(|item| item.get("story_id").and_then(|s| s.as_str())) + .map(|s| s.to_string()) + .collect(); + line.push_str(&format!(" | blocked: {}", ids.join(", "))); + } + + lines.push(line); + } + } + if lines.is_empty() { + return "No projects registered.".to_string(); + } + format!("**All Projects**\n\n{}", lines.join("\n\n")) +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn format_healthy_project() { + let mut statuses = BTreeMap::new(); + statuses.insert( + "huskies".to_string(), + json!({ + "counts": { "backlog": 5, "current": 2, "qa": 1, "merge": 0, "done": 12 }, + "blocked": [] + }), + ); + let output = format_aggregate_status_compact(&statuses); + assert!(output.contains("huskies")); + assert!(output.contains("B:5")); + assert!(output.contains("C:2")); + assert!(output.contains("Q:1")); + assert!(output.contains("D:12")); + assert!(!output.contains("blocked:")); + } + + #[test] + fn format_unreachable_project() { + let mut statuses = BTreeMap::new(); + statuses.insert( + "broken".to_string(), + json!({ "error": "connection refused" }), + ); + let output = format_aggregate_status_compact(&statuses); + assert!(output.contains("broken")); + assert!(output.contains("UNREACHABLE")); + assert!(output.contains("connection refused")); + } + + #[test] + fn format_blocked_items_shown() { + let mut statuses = BTreeMap::new(); + statuses.insert( + "myproj".to_string(), + json!({ + "counts": { "backlog": 0, "current": 1, "qa": 0, "merge": 0, "done": 0 }, + "blocked": [{ "story_id": "42_story_x", "name": "X", "stage": "current", "reason": "blocked" }] + }), + ); + let output = format_aggregate_status_compact(&statuses); + assert!(output.contains("blocked:")); + assert!(output.contains("42_story_x")); + } + + #[test] + fn format_empty_projects() { + let statuses = BTreeMap::new(); + let output = format_aggregate_status_compact(&statuses); + assert_eq!(output, "No projects registered."); + } +} diff --git a/server/src/service/gateway/config.rs b/server/src/service/gateway/config.rs new file mode 100644 index 00000000..24b88065 --- /dev/null +++ b/server/src/service/gateway/config.rs @@ -0,0 +1,191 @@ +//! Gateway configuration types — pure parsing and validation. +//! +//! Contains `ProjectEntry`, `GatewayConfig`, and validation logic. +//! All filesystem I/O (loading from disk) lives in `io.rs`. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// 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, +} + +/// Validate that a gateway config has at least one project. +/// +/// Returns the name of the first project (alphabetically) on success, +/// or an error message if the config is empty. +pub fn validate_config(config: &GatewayConfig) -> Result { + if config.projects.is_empty() { + return Err("projects.toml must define at least one project".to_string()); + } + Ok(config.projects.keys().next().unwrap().clone()) +} + +/// Validate that a project name exists in the given project map. +/// +/// Returns the project's URL on success. +pub fn validate_project_exists( + projects: &BTreeMap, + name: &str, +) -> Result { + projects.get(name).map(|p| p.url.clone()).ok_or_else(|| { + let available: Vec<&str> = projects.keys().map(|s| s.as_str()).collect(); + format!( + "unknown project '{name}'. Available: {}", + available.join(", ") + ) + }) +} + +/// Escape a string as a TOML quoted string. +pub fn toml_string(s: &str) -> String { + format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) +} + +/// Serialize a `bot.toml` content string from the given fields. +pub fn serialize_bot_config( + transport: &str, + homeserver: Option<&str>, + username: Option<&str>, + password: Option<&str>, + slack_bot_token: Option<&str>, + slack_signing_secret: Option<&str>, +) -> String { + match transport { + "slack" => { + format!( + "enabled = true\ntransport = \"slack\"\n\nslack_bot_token = {}\nslack_signing_secret = {}\nslack_channel_ids = []\n", + toml_string(slack_bot_token.unwrap_or("")), + toml_string(slack_signing_secret.unwrap_or("")), + ) + } + _ => { + format!( + "enabled = true\ntransport = \"matrix\"\n\nhomeserver = {}\nusername = {}\npassword = {}\nroom_ids = []\nallowed_users = []\n", + toml_string(homeserver.unwrap_or("")), + toml_string(username.unwrap_or("")), + toml_string(password.unwrap_or("")), + ) + } + } +} + +// ── 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 validate_config_rejects_empty() { + let config = GatewayConfig { + projects: BTreeMap::new(), + }; + assert!(validate_config(&config).is_err()); + } + + #[test] + fn validate_config_returns_first_project_name() { + let mut projects = BTreeMap::new(); + projects.insert( + "beta".into(), + ProjectEntry { + url: "http://b".into(), + }, + ); + projects.insert( + "alpha".into(), + ProjectEntry { + url: "http://a".into(), + }, + ); + let config = GatewayConfig { projects }; + assert_eq!(validate_config(&config).unwrap(), "alpha"); + } + + #[test] + fn validate_project_exists_succeeds() { + let mut projects = BTreeMap::new(); + projects.insert( + "p1".into(), + ProjectEntry { + url: "http://p1".into(), + }, + ); + assert_eq!( + validate_project_exists(&projects, "p1").unwrap(), + "http://p1" + ); + } + + #[test] + fn validate_project_exists_fails() { + let projects = BTreeMap::new(); + assert!(validate_project_exists(&projects, "missing").is_err()); + } + + #[test] + fn toml_string_escapes_quotes() { + assert_eq!(toml_string(r#"a"b"#), r#""a\"b""#); + } + + #[test] + fn toml_string_escapes_backslashes() { + assert_eq!(toml_string(r"a\b"), r#""a\\b""#); + } + + #[test] + fn serialize_bot_config_matrix() { + let content = serialize_bot_config( + "matrix", + Some("https://mx.io"), + Some("@bot:mx.io"), + Some("pass"), + None, + None, + ); + assert!(content.contains("transport = \"matrix\"")); + assert!(content.contains("homeserver = \"https://mx.io\"")); + } + + #[test] + fn serialize_bot_config_slack() { + let content = + serialize_bot_config("slack", None, None, None, Some("xoxb-123"), Some("secret")); + assert!(content.contains("transport = \"slack\"")); + assert!(content.contains("slack_bot_token = \"xoxb-123\"")); + } +} diff --git a/server/src/service/gateway/io.rs b/server/src/service/gateway/io.rs new file mode 100644 index 00000000..f5cedbd7 --- /dev/null +++ b/server/src/service/gateway/io.rs @@ -0,0 +1,407 @@ +//! Gateway I/O — the ONLY place in `service/gateway/` that may perform side effects. +//! +//! Side effects here include: reading/writing config and agent state files, +//! HTTP requests to project containers (proxying, health checks, polling), +//! spawning the Matrix bot task, and the notification poller background task. + +use super::config::{GatewayConfig, ProjectEntry}; +use super::registration::JoinedAgent; +pub use reqwest::Client; +use serde_json::{Value, json}; +use std::collections::{BTreeMap, HashMap}; +use std::path::Path; + +// ── Config I/O ─────────────────────────────────────────────────────────────── + +/// Load gateway config from a `projects.toml` file. +pub fn load_config(path: &Path) -> Result { + let contents = std::fs::read_to_string(path) + .map_err(|e| format!("cannot read {}: {e}", path.display()))?; + toml::from_str(&contents).map_err(|e| format!("invalid projects.toml: {e}")) +} + +/// Load persisted agents from `/gateway_agents.json`. +/// Returns an empty list if the file does not exist or cannot be parsed. +pub fn load_agents(config_dir: &Path) -> Vec { + let path = config_dir.join("gateway_agents.json"); + match std::fs::read(&path) { + Ok(data) => serde_json::from_slice(&data).unwrap_or_default(), + Err(_) => Vec::new(), + } +} + +/// Persist the current projects map to `/projects.toml`. +/// Silently ignores write errors or skips when `config_dir` is empty. +pub async fn save_config(projects: &BTreeMap, config_dir: &Path) { + if config_dir.as_os_str().is_empty() { + return; + } + let path = config_dir.join("projects.toml"); + let config = GatewayConfig { + projects: projects.clone(), + }; + if let Ok(data) = toml::to_string_pretty(&config) { + let _ = tokio::fs::write(&path, data).await; + } +} + +/// Persist the current agent list to `/gateway_agents.json`. +/// Silently ignores write errors. +pub async fn save_agents(agents: &[JoinedAgent], config_dir: &Path) { + if config_dir == Path::new("") { + return; + } + let path = config_dir.join("gateway_agents.json"); + if let Ok(data) = serde_json::to_vec_pretty(agents) { + let _ = tokio::fs::write(&path, data).await; + } +} + +// ── Bot config I/O ────────────────────────────────────────────────────────── + +/// Read the current raw bot.toml as key/value pairs for the configuration UI. +/// Returns `None` values if the file does not exist. +pub fn read_bot_config_raw(config_dir: &Path) -> BotConfigFields { + let path = config_dir.join(".huskies").join("bot.toml"); + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => return BotConfigFields::default(), + }; + let table: toml::Value = match toml::from_str(&content) { + Ok(v) => v, + Err(_) => return BotConfigFields::default(), + }; + let s = |key: &str| -> Option { + table + .get(key) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }; + BotConfigFields { + transport: s("transport").unwrap_or_else(|| "matrix".to_string()), + homeserver: s("homeserver"), + username: s("username"), + password: s("password"), + slack_bot_token: s("slack_bot_token"), + slack_signing_secret: s("slack_signing_secret"), + } +} + +/// Raw bot.toml fields for the configuration UI. +#[derive(Default)] +pub struct BotConfigFields { + pub transport: String, + pub homeserver: Option, + pub username: Option, + pub password: Option, + pub slack_bot_token: Option, + pub slack_signing_secret: Option, +} + +/// Write a `bot.toml` from the given content string. +pub fn write_bot_config(config_dir: &Path, content: &str) -> Result<(), String> { + let huskies_dir = config_dir.join(".huskies"); + std::fs::create_dir_all(&huskies_dir) + .map_err(|e| format!("cannot create .huskies dir: {e}"))?; + let path = huskies_dir.join("bot.toml"); + std::fs::write(&path, content).map_err(|e| format!("cannot write bot.toml: {e}")) +} + +// ── MCP proxy I/O ─────────────────────────────────────────────────────────── + +/// Proxy a raw MCP request body to the given project URL. +pub async fn proxy_mcp_call( + client: &Client, + base_url: &str, + request_bytes: &[u8], +) -> Result, String> { + let mcp_url = format!("{}/mcp", base_url.trim_end_matches('/')); + + let resp = client + .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}")) +} + +/// Fetch tools/list from a project's MCP endpoint. +pub async fn fetch_tools_list(client: &Client, base_url: &str) -> Result { + let mcp_url = format!("{}/mcp", base_url.trim_end_matches('/')); + + let rpc_body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": {} + }); + + let resp = client + .post(&mcp_url) + .json(&rpc_body) + .send() + .await + .map_err(|e| format!("failed to reach {mcp_url}: {e}"))?; + + resp.json() + .await + .map_err(|e| format!("invalid JSON from upstream: {e}")) +} + +/// Fetch and aggregate pipeline status for a single project URL. +pub async fn fetch_one_project_pipeline_status(url: &str, client: &Client) -> Value { + 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 client.post(&mcp_url).json(&rpc_body).send().await { + Ok(resp) => match resp.json::().await { + Ok(upstream) => { + if let Some(text) = upstream + .get("result") + .and_then(|r| r.get("content")) + .and_then(|c| c.get(0)) + .and_then(|c| c.get("text")) + .and_then(|t| t.as_str()) + { + match serde_json::from_str::(text) { + Ok(pipeline) => { + crate::service::pipeline::aggregate_pipeline_counts(&pipeline) + } + Err(_) => json!({ "error": "invalid pipeline JSON" }), + } + } else { + json!({ "error": "unexpected response shape" }) + } + } + Err(e) => json!({ "error": format!("invalid response: {e}") }), + }, + Err(e) => json!({ "error": format!("unreachable: {e}") }), + } +} + +/// Fetch `get_pipeline_status` from every registered project URL in parallel. +pub async fn fetch_all_project_pipeline_statuses( + project_urls: &BTreeMap, + client: &Client, +) -> BTreeMap { + use futures::future::join_all; + + let futures: Vec<_> = project_urls + .iter() + .map(|(name, url)| { + let name = name.clone(); + let url = url.clone(); + let client = client.clone(); + async move { + let result = fetch_one_project_pipeline_status(&url, &client).await; + (name, result) + } + }) + .collect(); + + join_all(futures).await.into_iter().collect() +} + +/// Fetch the pipeline status from a single project for the `gateway_status` tool. +pub async fn fetch_pipeline_status_for_project( + client: &Client, + base_url: &str, +) -> Result { + let mcp_url = format!("{}/mcp", base_url.trim_end_matches('/')); + let rpc_body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "get_pipeline_status", + "arguments": {} + } + }); + + let resp = client + .post(&mcp_url) + .json(&rpc_body) + .send() + .await + .map_err(|e| format!("failed to reach {mcp_url}: {e}"))?; + + resp.json() + .await + .map_err(|e| format!("invalid upstream response: {e}")) +} + +/// Check health of a single project URL. +pub async fn check_project_health(client: &Client, base_url: &str) -> Result { + let health_url = format!("{}/health", base_url.trim_end_matches('/')); + match client.get(&health_url).send().await { + Ok(resp) => Ok(resp.status().is_success()), + Err(e) => Err(format!("unreachable: {e}")), + } +} + +// ── Gateway MCP JSON ──────────────────────────────────────────────────────── + +/// Write (or overwrite) a `.mcp.json` in `config_dir` that points Claude Code +/// CLI at the gateway's own `/mcp` endpoint. +pub 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 = 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(()) +} + +// ── Init project I/O ──────────────────────────────────────────────────────── + +/// Check if a path already has a `.huskies/` directory. +pub fn has_huskies_dir(path: &Path) -> bool { + path.join(".huskies").exists() +} + +/// Create a directory (and parents) if it does not exist. +pub fn ensure_directory(path: &Path) -> Result<(), String> { + if !path.exists() { + std::fs::create_dir_all(path) + .map_err(|e| format!("failed to create directory '{}': {e}", path.display()))?; + } + Ok(()) +} + +/// Scaffold a huskies project at the given path. +pub fn scaffold_project(path: &Path) -> Result<(), String> { + crate::io::fs::scaffold::scaffold_story_kit(path, 3001) +} + +/// Initialise wizard state at the given path. +pub fn init_wizard_state(path: &Path) { + crate::io::wizard::WizardState::init_if_missing(path); +} + +// ── Notification poller ───────────────────────────────────────────────────── + +/// Spawn a background task that polls events from all project servers. +pub fn spawn_gateway_notification_poller( + transport: std::sync::Arc, + room_ids: Vec, + project_urls: BTreeMap, + poll_interval_secs: u64, +) { + tokio::spawn(async move { + let client = Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap_or_else(|_| Client::new()); + let interval = std::time::Duration::from_secs(poll_interval_secs.max(1)); + + let mut last_ts: HashMap = project_urls + .keys() + .map(|name| (name.clone(), 0u64)) + .collect(); + + loop { + for (project_name, base_url) in &project_urls { + let since = last_ts.get(project_name).copied().unwrap_or(0); + let url = format!("{base_url}/api/events?since={since}"); + + let response = match client.get(&url).send().await { + Ok(r) => r, + Err(e) => { + crate::slog!( + "[gateway-poller] {project_name}: unreachable ({e}); skipping" + ); + continue; + } + }; + + let events: Vec = match response.json().await { + Ok(v) => v, + Err(e) => { + crate::slog!( + "[gateway-poller] {project_name}: failed to parse events: {e}" + ); + continue; + } + }; + + for event in &events { + let ts = event.timestamp_ms(); + if ts > *last_ts.get(project_name).unwrap_or(&0) { + last_ts.insert(project_name.clone(), ts); + } + + let (plain, html) = super::polling::format_gateway_event(project_name, event); + for room_id in &room_ids { + if let Err(e) = transport.send_message(room_id, &plain, &html).await { + crate::slog!( + "[gateway-poller] Failed to send notification to {room_id}: {e}" + ); + } + } + } + } + + tokio::time::sleep(interval).await; + } + }); +} + +// ── Gateway bot spawn ─────────────────────────────────────────────────────── + +/// Re-export type alias for the active project lock. +pub type ActiveProject = std::sync::Arc>; + +/// Attempt to spawn the Matrix bot against the gateway config directory. +pub fn spawn_gateway_bot( + config_dir: &Path, + active_project: ActiveProject, + gateway_projects: Vec, + gateway_project_urls: BTreeMap, + port: u16, +) -> Option { + use crate::agents::AgentPool; + use tokio::sync::{broadcast, mpsc}; + + let (watcher_tx, _) = broadcast::channel(16); + let (_perm_tx, perm_rx) = mpsc::unbounded_channel(); + let perm_rx = std::sync::Arc::new(tokio::sync::Mutex::new(perm_rx)); + + let (shutdown_tx, shutdown_rx) = + tokio::sync::watch::channel::>(None); + std::mem::forget(shutdown_tx); + + let agents = std::sync::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, + gateway_project_urls, + ) +} diff --git a/server/src/service/gateway/mod.rs b/server/src/service/gateway/mod.rs new file mode 100644 index 00000000..2d69d76c --- /dev/null +++ b/server/src/service/gateway/mod.rs @@ -0,0 +1,580 @@ +//! Gateway service — domain logic for the multi-project gateway. +//! +//! Follows the conventions in `docs/architecture/service-modules.md`: +//! - `mod.rs` (this file) — public API, typed [`Error`], orchestration, `GatewayState` +//! - `io.rs` — the ONLY place that performs side effects (filesystem, network, process spawn) +//! - `config.rs` — pure config types and validation +//! - `registration.rs` — pure agent registration logic +//! - `aggregation.rs` — pure cross-project pipeline formatting +//! - `polling.rs` — pure notification event formatting + +pub mod aggregation; +pub mod config; +pub(crate) mod io; +pub mod polling; +pub mod registration; + +pub use aggregation::format_aggregate_status_compact; +pub use config::{GatewayConfig, ProjectEntry}; +pub use io::{fetch_all_project_pipeline_statuses, spawn_gateway_notification_poller}; +pub use registration::JoinedAgent; + +use io::Client; +use std::collections::{BTreeMap, HashMap}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::Mutex as TokioMutex; +use tokio::sync::RwLock; + +// ── Error type ────────────────────────────────────────────────────────────── + +/// Typed errors returned by `service::gateway` functions. +/// +/// HTTP handlers map these to appropriate status codes: +/// - [`Error::ProjectNotFound`] → 404 Not Found +/// - [`Error::UnreachableProject`] → 502 Bad Gateway +/// - [`Error::DuplicateToken`] → 409 Conflict +/// - [`Error::InvalidAgent`] → 404 Not Found / 400 Bad Request +/// - [`Error::Config`] → 400 Bad Request +/// - [`Error::Upstream`] → 502 Bad Gateway +#[derive(Debug)] +pub enum Error { + /// A referenced project does not exist in the gateway config. + ProjectNotFound(String), + /// A project container is unreachable. + UnreachableProject(String), + /// A join token has already been consumed or a project name is taken. + DuplicateToken(String), + /// An agent ID is invalid or not found. + InvalidAgent(String), + /// A configuration value is invalid. + Config(String), + /// An upstream project container returned an unexpected response. + Upstream(String), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ProjectNotFound(msg) => write!(f, "Project not found: {msg}"), + Self::UnreachableProject(msg) => write!(f, "Unreachable project: {msg}"), + Self::DuplicateToken(msg) => write!(f, "Duplicate token: {msg}"), + Self::InvalidAgent(msg) => write!(f, "Invalid agent: {msg}"), + Self::Config(msg) => write!(f, "Config error: {msg}"), + Self::Upstream(msg) => write!(f, "Upstream error: {msg}"), + } + } +} + +// ── Gateway state ─────────────────────────────────────────────────────────── + +/// A one-time join token that has been generated but not yet consumed. +pub(crate) struct PendingToken { + #[allow(dead_code)] + pub(crate) created_at: f64, +} + +/// Shared gateway state threaded through HTTP handlers. +#[derive(Clone)] +pub struct GatewayState { + /// The live set of registered projects (initially loaded from `projects.toml`). + pub projects: Arc>>, + /// The currently active project name. + pub active_project: Arc>, + /// HTTP client for proxying requests to project containers. + pub client: Client, + /// Build agents that have joined this gateway. + pub joined_agents: Arc>>, + /// One-time join tokens that have been issued but not yet consumed. + pub(crate) pending_tokens: Arc>>, + /// Directory containing `projects.toml` and the `.huskies/` subfolder. + pub config_dir: PathBuf, + /// HTTP port the gateway is listening on. + pub port: u16, + /// Abort handle for the running Matrix bot task (if any). + pub bot_handle: Arc>>, +} + +impl GatewayState { + /// Create a new gateway state from a config and config directory. + /// + /// The first project in the config becomes the active project by default. + /// Previously registered agents are loaded from `gateway_agents.json`. + pub fn new( + gateway_config: GatewayConfig, + config_dir: PathBuf, + port: u16, + ) -> Result { + let first = config::validate_config(&gateway_config)?; + let agents = io::load_agents(&config_dir); + Ok(Self { + projects: Arc::new(RwLock::new(gateway_config.projects)), + active_project: Arc::new(RwLock::new(first)), + client: Client::new(), + joined_agents: Arc::new(RwLock::new(agents)), + pending_tokens: Arc::new(RwLock::new(HashMap::new())), + config_dir, + port, + bot_handle: Arc::new(TokioMutex::new(None)), + }) + } + + /// Get the URL of the currently active project. + pub async fn active_url(&self) -> Result { + let name = self.active_project.read().await.clone(); + self.projects + .read() + .await + .get(&name) + .map(|p| p.url.clone()) + .ok_or_else(|| { + Error::ProjectNotFound(format!("active project '{name}' not found in config")) + }) + } +} + +// ── Public API ────────────────────────────────────────────────────────────── + +/// Switch the active project. Returns the project's URL on success. +pub async fn switch_project(state: &GatewayState, project: &str) -> Result { + if project.is_empty() { + return Err(Error::Config("missing required parameter: project".into())); + } + + let url = { + let projects = state.projects.read().await; + config::validate_project_exists(&projects, project).map_err(Error::ProjectNotFound)? + }; + + *state.active_project.write().await = project.to_string(); + Ok(url) +} + +/// Generate a one-time join token. Returns the token string. +pub async fn generate_join_token(state: &GatewayState) -> String { + let token = uuid::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); + token +} + +/// Register a build agent with a join token. +pub async fn register_agent( + state: &GatewayState, + token: &str, + label: String, + address: String, +) -> Result { + // Validate and consume the token. + let mut tokens = state.pending_tokens.write().await; + if !tokens.contains_key(token) { + return Err(Error::DuplicateToken( + "invalid or already-used join token".into(), + )); + } + tokens.remove(token); + drop(tokens); + + let now = chrono::Utc::now().timestamp() as f64; + let agent = registration::create_agent(uuid::Uuid::new_v4().to_string(), label, address, now); + + crate::slog!( + "[gateway] Agent '{}' registered (id={})", + agent.label, + agent.id + ); + + { + let mut agents = state.joined_agents.write().await; + agents.push(agent.clone()); + io::save_agents(&agents, &state.config_dir).await; + } + + Ok(agent) +} + +/// Remove a registered agent by ID. Returns `true` if found and removed. +pub async fn remove_agent(state: &GatewayState, id: &str) -> bool { + let mut agents = state.joined_agents.write().await; + let removed = registration::remove_agent(&mut agents, id); + if removed { + io::save_agents(&agents, &state.config_dir).await; + crate::slog!("[gateway] Removed agent id={id}"); + } + removed +} + +/// Assign or unassign an agent to a project. +pub async fn assign_agent( + state: &GatewayState, + id: &str, + project: Option, +) -> Result { + let project_clean = project.and_then(|p| if p.is_empty() { None } else { Some(p) }); + + let updated = { + let projects = state.projects.read().await; + let mut agents = state.joined_agents.write().await; + registration::assign_agent(&mut agents, id, project_clean, &projects)? + }; + + crate::slog!( + "[gateway] Agent '{}' (id={}) assigned to {:?}", + updated.label, + updated.id, + updated.assigned_project + ); + let agents = state.joined_agents.read().await.clone(); + io::save_agents(&agents, &state.config_dir).await; + Ok(updated) +} + +/// Update an agent's heartbeat. Returns `true` if found. +pub async fn heartbeat_agent(state: &GatewayState, id: &str) -> bool { + let now = chrono::Utc::now().timestamp() as f64; + let mut agents = state.joined_agents.write().await; + registration::heartbeat(&mut agents, id, now) +} + +/// Add a new project to the gateway config. +pub async fn add_project(state: &GatewayState, name: &str, url: &str) -> Result<(), Error> { + let name = name.trim().to_string(); + let url = url.trim().to_string(); + + if name.is_empty() { + return Err(Error::Config("project name must not be empty".into())); + } + if url.is_empty() { + return Err(Error::Config("project url must not be empty".into())); + } + + { + let mut projects = state.projects.write().await; + if projects.contains_key(&name) { + return Err(Error::DuplicateToken(format!( + "project '{name}' already exists" + ))); + } + projects.insert(name.clone(), ProjectEntry { url: url.clone() }); + } + + let snapshot = state.projects.read().await.clone(); + io::save_config(&snapshot, &state.config_dir).await; + crate::slog!("[gateway] Added project '{name}' ({url})"); + Ok(()) +} + +/// Remove a project from the gateway config. +pub async fn remove_project(state: &GatewayState, name: &str) -> Result<(), Error> { + let active = state.active_project.read().await.clone(); + + { + let mut projects = state.projects.write().await; + if !projects.contains_key(name) { + return Err(Error::ProjectNotFound(format!( + "project '{name}' not found" + ))); + } + if projects.len() == 1 { + return Err(Error::Config("cannot remove the last project".into())); + } + projects.remove(name); + } + + let snapshot = state.projects.read().await.clone(); + io::save_config(&snapshot, &state.config_dir).await; + + // If the removed project was active, switch to the first remaining. + if active == name { + let first = state.projects.read().await.keys().next().cloned(); + if let Some(new_active) = first { + *state.active_project.write().await = new_active; + } + } + + crate::slog!("[gateway] Removed project '{name}'"); + Ok(()) +} + +/// Initialise a new huskies project at the given path. +/// +/// Optionally registers the project in the gateway's project map. +pub async fn init_project( + state: &GatewayState, + path_str: &str, + name: Option<&str>, + url: Option<&str>, +) -> Result, Error> { + let path_str = path_str.trim(); + if path_str.is_empty() { + return Err(Error::Config("missing required parameter: path".into())); + } + + let project_path = std::path::Path::new(path_str); + + if io::has_huskies_dir(project_path) { + return Err(Error::Config(format!( + "path '{}' is already a huskies project (.huskies/ exists). \ + Use wizard_status to check setup progress.", + project_path.display() + ))); + } + + io::ensure_directory(project_path).map_err(Error::Config)?; + + io::scaffold_project(project_path) + .map_err(|e| Error::Config(format!("scaffold failed: {e}")))?; + + io::init_wizard_state(project_path); + + // Optionally register in projects.toml. + let registered_name: Option = match (name, url) { + (Some(n), Some(u)) if !n.trim().is_empty() && !u.trim().is_empty() => { + let n = n.trim(); + let u = u.trim(); + let mut projects = state.projects.write().await; + if projects.contains_key(n) { + return Err(Error::DuplicateToken(format!( + "project '{n}' is already registered. Choose a different name or use switch_project." + ))); + } + projects.insert(n.to_string(), ProjectEntry { url: u.to_string() }); + io::save_config(&projects, &state.config_dir).await; + crate::slog!("[gateway] init_project: registered '{n}' ({u})"); + Some(n.to_string()) + } + _ => None, + }; + + Ok(registered_name) +} + +/// Fetch aggregated health status across all projects. +pub async fn health_check_all(state: &GatewayState) -> (bool, BTreeMap) { + let mut all_healthy = true; + let mut statuses = BTreeMap::new(); + + let project_entries: Vec<(String, String)> = state + .projects + .read() + .await + .iter() + .map(|(n, e)| (n.clone(), e.url.clone())) + .collect(); + + for (name, url) in &project_entries { + let healthy = io::check_project_health(&state.client, url) + .await + .unwrap_or(false); + if !healthy { + all_healthy = false; + } + statuses.insert(name.clone(), if healthy { "ok" } else { "error" }); + } + + (all_healthy, statuses) +} + +/// Save bot config and restart the bot. +pub async fn save_bot_config_and_restart(state: &GatewayState, content: &str) -> Result<(), Error> { + io::write_bot_config(&state.config_dir, content).map_err(Error::Config)?; + + // Abort existing bot task and spawn a fresh one. + { + let mut handle = state.bot_handle.lock().await; + if let Some(h) = handle.take() { + h.abort(); + } + let gateway_projects: Vec = state.projects.read().await.keys().cloned().collect(); + let gateway_project_urls: BTreeMap = state + .projects + .read() + .await + .iter() + .map(|(name, entry)| (name.clone(), entry.url.clone())) + .collect(); + + let new_handle = io::spawn_gateway_bot( + &state.config_dir, + Arc::clone(&state.active_project), + gateway_projects, + gateway_project_urls, + state.port, + ); + *handle = new_handle; + } + + crate::slog!("[gateway] Bot configuration saved; bot restarted"); + Ok(()) +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn make_config(names: &[(&str, &str)]) -> GatewayConfig { + let mut projects = BTreeMap::new(); + for (name, url) in names { + projects.insert( + name.to_string(), + ProjectEntry { + url: url.to_string(), + }, + ); + } + GatewayConfig { projects } + } + + #[test] + fn gateway_state_rejects_empty_config() { + let config = GatewayConfig { + projects: BTreeMap::new(), + }; + assert!(GatewayState::new(config, PathBuf::from("."), 3000).is_err()); + } + + #[test] + fn gateway_state_sets_first_project_active() { + let config = make_config(&[("alpha", "http://a:3001"), ("beta", "http://b:3002")]); + let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap(); + let active = state.active_project.blocking_read().clone(); + assert_eq!(active, "alpha"); + } + + #[tokio::test] + async fn switch_project_to_known_project() { + let config = make_config(&[("alpha", "http://a:3001"), ("beta", "http://b:3002")]); + let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap(); + let url = switch_project(&state, "beta").await.unwrap(); + assert_eq!(url, "http://b:3002"); + assert_eq!(*state.active_project.read().await, "beta"); + } + + #[tokio::test] + async fn switch_project_to_unknown_fails() { + let config = make_config(&[("alpha", "http://a:3001")]); + let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap(); + assert!(switch_project(&state, "nonexistent").await.is_err()); + } + + #[tokio::test] + async fn switch_project_empty_name_fails() { + let config = make_config(&[("alpha", "http://a:3001")]); + let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap(); + assert!(switch_project(&state, "").await.is_err()); + } + + #[tokio::test] + async fn active_url_returns_correct_url() { + let config = make_config(&[("myproj", "http://my:3001")]); + let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap(); + let url = state.active_url().await.unwrap(); + assert_eq!(url, "http://my:3001"); + } + + #[test] + fn error_display_variants() { + assert!( + Error::ProjectNotFound("x".into()) + .to_string() + .contains("Project not found") + ); + assert!( + Error::UnreachableProject("x".into()) + .to_string() + .contains("Unreachable") + ); + assert!( + Error::DuplicateToken("x".into()) + .to_string() + .contains("Duplicate") + ); + assert!( + Error::InvalidAgent("x".into()) + .to_string() + .contains("Invalid agent") + ); + assert!( + Error::Config("x".into()) + .to_string() + .contains("Config error") + ); + assert!(Error::Upstream("x".into()).to_string().contains("Upstream")); + } + + #[tokio::test] + async fn generate_and_register_agent() { + let config = make_config(&[("test", "http://test:3001")]); + let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap(); + let token = generate_join_token(&state).await; + let agent = register_agent(&state, &token, "test-agent".into(), "ws://a".into()) + .await + .unwrap(); + assert_eq!(agent.label, "test-agent"); + assert!(state.pending_tokens.read().await.is_empty()); + assert_eq!(state.joined_agents.read().await.len(), 1); + } + + #[tokio::test] + async fn register_agent_invalid_token_fails() { + let config = make_config(&[("test", "http://test:3001")]); + let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap(); + let result = register_agent(&state, "bad-token", "a".into(), "ws://a".into()).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn remove_agent_success() { + let config = make_config(&[("test", "http://test:3001")]); + let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap(); + let token = generate_join_token(&state).await; + let agent = register_agent(&state, &token, "a".into(), "ws://a".into()) + .await + .unwrap(); + assert!(remove_agent(&state, &agent.id).await); + assert!(state.joined_agents.read().await.is_empty()); + } + + #[tokio::test] + async fn heartbeat_agent_updates_timestamp() { + let config = make_config(&[("test", "http://test:3001")]); + let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap(); + let token = generate_join_token(&state).await; + let agent = register_agent(&state, &token, "a".into(), "ws://a".into()) + .await + .unwrap(); + let old_ts = agent.last_seen; + // Small sleep to ensure timestamp differs. + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + assert!(heartbeat_agent(&state, &agent.id).await); + let agents = state.joined_agents.read().await; + assert!(agents[0].last_seen >= old_ts); + } + + #[tokio::test] + async fn init_project_scaffolds_directory() { + let dir = tempfile::tempdir().unwrap(); + let config = make_config(&[("test", "http://test:3001")]); + let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap(); + let result = init_project(&state, dir.path().to_str().unwrap(), None, None).await; + assert!(result.is_ok()); + assert!(dir.path().join(".huskies").exists()); + } + + #[tokio::test] + async fn init_project_already_exists_fails() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); + let config = make_config(&[("test", "http://test:3001")]); + let state = GatewayState::new(config, PathBuf::new(), 3000).unwrap(); + let result = init_project(&state, dir.path().to_str().unwrap(), None, None).await; + assert!(result.is_err()); + } +} diff --git a/server/src/service/gateway/polling.rs b/server/src/service/gateway/polling.rs new file mode 100644 index 00000000..02587137 --- /dev/null +++ b/server/src/service/gateway/polling.rs @@ -0,0 +1,91 @@ +//! Gateway notification polling — pure event formatting. +//! +//! Formats pipeline events from project containers into gateway notifications +//! with `[project-name]` prefixes. The actual I/O (HTTP polling, spawning +//! tasks, sending messages) lives in `io.rs`. + +use crate::service::events::StoredEvent; +use crate::service::notifications::{ + format_blocked_notification, format_error_notification, format_stage_notification, + stage_display_name, +}; + +/// Format a [`StoredEvent`] from a project into a gateway notification. +/// +/// Prefixes the message with `[project-name]` so users can distinguish which +/// project emitted the event. +pub fn format_gateway_event(project_name: &str, event: &StoredEvent) -> (String, String) { + let prefix = format!("[{project_name}] "); + + match event { + StoredEvent::StageTransition { + story_id, + from_stage, + to_stage, + .. + } => { + let from_display = stage_display_name(from_stage); + let to_display = stage_display_name(to_stage); + let (plain, html) = format_stage_notification(story_id, None, from_display, to_display); + (format!("{prefix}{plain}"), format!("{prefix}{html}")) + } + StoredEvent::MergeFailure { + story_id, reason, .. + } => { + let (plain, html) = format_error_notification(story_id, None, reason); + (format!("{prefix}{plain}"), format!("{prefix}{html}")) + } + StoredEvent::StoryBlocked { + story_id, reason, .. + } => { + let (plain, html) = format_blocked_notification(story_id, None, reason); + (format!("{prefix}{plain}"), format!("{prefix}{html}")) + } + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stage_transition_prefixes_project_name() { + let event = StoredEvent::StageTransition { + story_id: "42_story_my_feature".to_string(), + from_stage: "2_current".to_string(), + to_stage: "3_qa".to_string(), + timestamp_ms: 1000, + }; + let (plain, html) = format_gateway_event("huskies", &event); + assert!(plain.starts_with("[huskies] ")); + assert!(html.starts_with("[huskies] ")); + assert!(plain.contains("Current")); + assert!(plain.contains("QA")); + } + + #[test] + fn merge_failure_prefixes_project_name() { + let event = StoredEvent::MergeFailure { + story_id: "42_story_my_feature".to_string(), + reason: "merge conflict".to_string(), + timestamp_ms: 1000, + }; + let (plain, _html) = format_gateway_event("robot-studio", &event); + assert!(plain.starts_with("[robot-studio] ")); + assert!(plain.contains("merge conflict")); + } + + #[test] + fn story_blocked_prefixes_project_name() { + let event = StoredEvent::StoryBlocked { + story_id: "43_story_bar".to_string(), + reason: "retry limit exceeded".to_string(), + timestamp_ms: 2000, + }; + let (plain, _html) = format_gateway_event("huskies", &event); + assert!(plain.starts_with("[huskies] ")); + assert!(plain.contains("BLOCKED")); + } +} diff --git a/server/src/service/gateway/registration.rs b/server/src/service/gateway/registration.rs new file mode 100644 index 00000000..6f00f1de --- /dev/null +++ b/server/src/service/gateway/registration.rs @@ -0,0 +1,165 @@ +//! Gateway agent registration — pure logic for managing build agents. +//! +//! Contains `JoinedAgent` and functions that validate and manipulate agent +//! state in memory. All persistence (disk I/O) lives in `io.rs`. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +use super::config::ProjectEntry; + +/// 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, + /// Unix timestamp of the last heartbeat from this agent. + #[serde(default)] + pub last_seen: f64, + /// Project this agent is assigned to, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub assigned_project: Option, +} + +/// Create a new `JoinedAgent` from registration data. +pub fn create_agent(id: String, label: String, address: String, now: f64) -> JoinedAgent { + JoinedAgent { + id, + label, + address, + registered_at: now, + last_seen: now, + assigned_project: None, + } +} + +/// Remove an agent by ID from the list. Returns `true` if found and removed. +pub fn remove_agent(agents: &mut Vec, id: &str) -> bool { + let before = agents.len(); + agents.retain(|a| a.id != id); + agents.len() < before +} + +/// Assign (or unassign) an agent to a project. +/// +/// Returns the updated agent on success, or an error if the agent or project +/// is not found. +pub fn assign_agent( + agents: &mut [JoinedAgent], + id: &str, + project: Option, + projects: &BTreeMap, +) -> Result { + // Validate project exists if assigning. + if let Some(ref p) = project + && !projects.contains_key(p.as_str()) + { + return Err(super::Error::ProjectNotFound(format!( + "unknown project '{p}'" + ))); + } + + match agents.iter_mut().find(|a| a.id == id) { + None => Err(super::Error::InvalidAgent(format!("agent not found: {id}"))), + Some(a) => { + a.assigned_project = project; + Ok(a.clone()) + } + } +} + +/// Update an agent's last-seen timestamp. Returns `true` if the agent was found. +pub fn heartbeat(agents: &mut [JoinedAgent], id: &str, now: f64) -> bool { + match agents.iter_mut().find(|a| a.id == id) { + None => false, + Some(a) => { + a.last_seen = now; + true + } + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_agent_sets_fields() { + let agent = create_agent("id-1".into(), "lbl".into(), "ws://a".into(), 100.0); + assert_eq!(agent.id, "id-1"); + assert_eq!(agent.label, "lbl"); + assert_eq!(agent.address, "ws://a"); + assert_eq!(agent.registered_at, 100.0); + assert_eq!(agent.last_seen, 100.0); + assert!(agent.assigned_project.is_none()); + } + + #[test] + fn remove_agent_by_id() { + let mut agents = vec![ + create_agent("a".into(), "A".into(), "ws://a".into(), 0.0), + create_agent("b".into(), "B".into(), "ws://b".into(), 0.0), + ]; + assert!(remove_agent(&mut agents, "a")); + assert_eq!(agents.len(), 1); + assert_eq!(agents[0].id, "b"); + } + + #[test] + fn remove_agent_missing_returns_false() { + let mut agents = vec![]; + assert!(!remove_agent(&mut agents, "x")); + } + + #[test] + fn assign_agent_to_valid_project() { + let mut projects = BTreeMap::new(); + projects.insert( + "proj".into(), + ProjectEntry { + url: "http://p".into(), + }, + ); + let mut agents = vec![create_agent("a".into(), "A".into(), "ws://a".into(), 0.0)]; + let result = assign_agent(&mut agents, "a", Some("proj".into()), &projects); + assert!(result.is_ok()); + assert_eq!(result.unwrap().assigned_project, Some("proj".into())); + } + + #[test] + fn assign_agent_to_unknown_project_fails() { + let projects = BTreeMap::new(); + let mut agents = vec![create_agent("a".into(), "A".into(), "ws://a".into(), 0.0)]; + let result = assign_agent(&mut agents, "a", Some("nope".into()), &projects); + assert!(result.is_err()); + } + + #[test] + fn assign_agent_unknown_id_fails() { + let projects = BTreeMap::new(); + let mut agents: Vec = vec![]; + let result = assign_agent(&mut agents, "x", None, &projects); + assert!(result.is_err()); + } + + #[test] + fn heartbeat_updates_last_seen() { + let mut agents = vec![create_agent("a".into(), "A".into(), "ws://a".into(), 0.0)]; + assert!(heartbeat(&mut agents, "a", 999.0)); + assert_eq!(agents[0].last_seen, 999.0); + } + + #[test] + fn heartbeat_unknown_id_returns_false() { + let mut agents: Vec = vec![]; + assert!(!heartbeat(&mut agents, "x", 1.0)); + } +} diff --git a/server/src/service/mod.rs b/server/src/service/mod.rs index 4a7fa24c..e10e396b 100644 --- a/server/src/service/mod.rs +++ b/server/src/service/mod.rs @@ -18,3 +18,6 @@ pub mod settings; pub mod timer; pub mod wizard; pub mod ws; + +pub mod gateway; +pub mod pipeline; diff --git a/server/src/service/pipeline/mod.rs b/server/src/service/pipeline/mod.rs new file mode 100644 index 00000000..76c3e185 --- /dev/null +++ b/server/src/service/pipeline/mod.rs @@ -0,0 +1,155 @@ +//! Pipeline service — shared pipeline-domain logic. +//! +//! Contains pure functions for parsing and aggregating pipeline status data. +//! Used by the gateway service for cross-project aggregation and potentially +//! by other consumers that need to reason about pipeline stage counts. + +use serde_json::{Value, json}; + +/// Parse a `get_pipeline_status` JSON payload and produce aggregated counts +/// plus a list of blocked/failing items. +pub fn aggregate_pipeline_counts(pipeline: &Value) -> Value { + let active = pipeline + .get("active") + .and_then(|a| a.as_array()) + .cloned() + .unwrap_or_default(); + let backlog_count = pipeline + .get("backlog_count") + .and_then(|n| n.as_u64()) + .unwrap_or(0); + + let mut current = 0u64; + let mut qa = 0u64; + let mut merge = 0u64; + let mut done = 0u64; + let mut blocked: Vec = Vec::new(); + + for item in &active { + let stage = item + .get("stage") + .and_then(|s| s.as_str()) + .unwrap_or("unknown"); + match stage { + "current" => current += 1, + "qa" => qa += 1, + "merge" => merge += 1, + "done" => done += 1, + _ => {} + } + + let is_blocked = item + .get("blocked") + .and_then(|b| b.as_bool()) + .unwrap_or(false); + let merge_failure = item.get("merge_failure"); + let has_merge_failure = merge_failure + .map(|f| !f.is_null() && f != "") + .unwrap_or(false); + + if is_blocked || has_merge_failure { + let story_id = item + .get("story_id") + .and_then(|s| s.as_str()) + .unwrap_or("?") + .to_string(); + let story_name = item + .get("name") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(); + let reason = if has_merge_failure { + format!( + "merge failure: {}", + merge_failure.and_then(|f| f.as_str()).unwrap_or("unknown") + ) + } else { + let rc = item + .get("retry_count") + .and_then(|n| n.as_u64()) + .unwrap_or(0); + format!("blocked after {rc} retries") + }; + blocked.push(json!({ + "story_id": story_id, + "name": story_name, + "stage": stage, + "reason": reason, + })); + } + } + + json!({ + "counts": { + "backlog": backlog_count, + "current": current, + "qa": qa, + "merge": merge, + "done": done, + }, + "blocked": blocked, + }) +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aggregate_empty_pipeline() { + let pipeline = json!({ "active": [], "backlog": [], "backlog_count": 0 }); + let result = aggregate_pipeline_counts(&pipeline); + assert_eq!(result["counts"]["backlog"], 0); + assert_eq!(result["counts"]["current"], 0); + assert_eq!(result["counts"]["qa"], 0); + assert_eq!(result["counts"]["merge"], 0); + assert_eq!(result["counts"]["done"], 0); + assert_eq!(result["blocked"].as_array().unwrap().len(), 0); + } + + #[test] + fn aggregate_stage_counts_correct() { + let pipeline = json!({ + "active": [ + { "story_id": "1_story_a", "name": "A", "stage": "current" }, + { "story_id": "2_story_b", "name": "B", "stage": "current" }, + { "story_id": "3_story_c", "name": "C", "stage": "qa" }, + { "story_id": "4_story_d", "name": "D", "stage": "done" }, + ], + "backlog": [{ "story_id": "5_story_e", "name": "E" }, { "story_id": "6_story_f", "name": "F" }], + "backlog_count": 2 + }); + let result = aggregate_pipeline_counts(&pipeline); + assert_eq!(result["counts"]["backlog"], 2); + assert_eq!(result["counts"]["current"], 2); + assert_eq!(result["counts"]["qa"], 1); + assert_eq!(result["counts"]["merge"], 0); + assert_eq!(result["counts"]["done"], 1); + assert_eq!(result["blocked"].as_array().unwrap().len(), 0); + } + + #[test] + fn aggregate_blocked_items_captured() { + let pipeline = json!({ + "active": [ + { "story_id": "10_story_blocked", "name": "Blocked", "stage": "current", "blocked": true, "retry_count": 3 }, + { "story_id": "20_story_ok", "name": "OK", "stage": "qa" }, + ], + "backlog": [], + "backlog_count": 0 + }); + let result = aggregate_pipeline_counts(&pipeline); + let blocked = result["blocked"].as_array().unwrap(); + assert_eq!(blocked.len(), 1); + assert_eq!(blocked[0]["story_id"], "10_story_blocked"); + assert_eq!(blocked[0]["stage"], "current"); + assert!( + blocked[0]["reason"] + .as_str() + .unwrap() + .contains("blocked after 3 retries"), + ); + } +}