2026-04-24 18:39:16 +00:00
|
|
|
//! Multi-project gateway — entrypoint wiring and route tree.
|
2026-04-13 13:02:41 +00:00
|
|
|
//!
|
2026-04-24 18:39:16 +00:00
|
|
|
//! 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.
|
2026-04-13 13:02:41 +00:00
|
|
|
|
2026-04-24 18:39:16 +00:00
|
|
|
use crate::http::gateway::*;
|
|
|
|
|
use crate::service::gateway::{self, GatewayState};
|
2026-04-13 13:02:41 +00:00
|
|
|
use poem::EndpointExt;
|
2026-04-24 18:39:16 +00:00
|
|
|
use std::path::Path;
|
2026-04-13 13:02:41 +00:00
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
2026-04-24 18:39:16 +00:00
|
|
|
// Re-export public types that callers reference as `crate::gateway::*`.
|
|
|
|
|
pub use crate::service::gateway::{
|
2026-04-28 00:17:44 +00:00
|
|
|
GatewayConfig, GatewayState as GatewayStateType, GatewayStatusEvent, JoinedAgent, ProjectEntry,
|
|
|
|
|
broadcast_status_event, fetch_all_project_pipeline_statuses, format_aggregate_status_compact,
|
2026-04-28 01:27:00 +00:00
|
|
|
spawn_gateway_broadcaster_forwarder, spawn_gateway_notification_poller,
|
|
|
|
|
subscribe_status_events,
|
2026-04-24 18:39:16 +00:00
|
|
|
};
|
2026-04-13 13:02:41 +00:00
|
|
|
|
2026-04-15 19:50:27 +00:00
|
|
|
/// Build the complete gateway route tree.
|
|
|
|
|
///
|
|
|
|
|
/// Extracted from `run` so that tests can construct the full route tree and
|
|
|
|
|
/// catch duplicate-route panics before they reach production.
|
|
|
|
|
pub fn build_gateway_route(state_arc: Arc<GatewayState>) -> impl poem::Endpoint {
|
|
|
|
|
poem::Route::new()
|
2026-04-14 18:53:41 +00:00
|
|
|
.at("/bot-config", poem::get(gateway_bot_config_page_handler))
|
2026-04-14 11:24:12 +00:00
|
|
|
.at("/api/gateway", poem::get(gateway_api_handler))
|
|
|
|
|
.at("/api/gateway/switch", poem::post(gateway_switch_handler))
|
2026-04-15 18:34:37 +00:00
|
|
|
.at(
|
|
|
|
|
"/api/gateway/pipeline",
|
|
|
|
|
poem::get(gateway_all_pipeline_handler),
|
|
|
|
|
)
|
2026-04-15 18:02:47 +00:00
|
|
|
.at(
|
|
|
|
|
"/api/gateway/projects",
|
|
|
|
|
poem::post(gateway_add_project_handler),
|
|
|
|
|
)
|
|
|
|
|
.at(
|
|
|
|
|
"/api/gateway/projects/:name",
|
|
|
|
|
poem::delete(gateway_remove_project_handler),
|
|
|
|
|
)
|
2026-04-14 18:53:41 +00:00
|
|
|
.at(
|
|
|
|
|
"/api/gateway/bot-config",
|
|
|
|
|
poem::get(gateway_bot_config_get_handler).post(gateway_bot_config_save_handler),
|
|
|
|
|
)
|
2026-04-13 13:02:41 +00:00
|
|
|
.at(
|
|
|
|
|
"/mcp",
|
|
|
|
|
poem::post(gateway_mcp_post_handler).get(gateway_mcp_get_handler),
|
|
|
|
|
)
|
|
|
|
|
.at("/health", poem::get(gateway_health_handler))
|
2026-04-14 12:02:17 +00:00
|
|
|
// Agent join endpoints.
|
|
|
|
|
.at("/gateway/mode", poem::get(gateway_mode_handler))
|
|
|
|
|
.at(
|
|
|
|
|
"/gateway/tokens",
|
|
|
|
|
poem::post(gateway_generate_token_handler),
|
|
|
|
|
)
|
|
|
|
|
.at(
|
|
|
|
|
"/gateway/register",
|
|
|
|
|
poem::post(gateway_register_agent_handler),
|
|
|
|
|
)
|
|
|
|
|
.at("/gateway/agents", poem::get(gateway_list_agents_handler))
|
|
|
|
|
.at(
|
|
|
|
|
"/gateway/agents/:id",
|
|
|
|
|
poem::delete(gateway_remove_agent_handler),
|
|
|
|
|
)
|
2026-04-14 12:25:12 +00:00
|
|
|
.at(
|
|
|
|
|
"/gateway/agents/:id/assign",
|
|
|
|
|
poem::post(gateway_assign_agent_handler),
|
|
|
|
|
)
|
2026-04-15 18:20:39 +00:00
|
|
|
.at(
|
|
|
|
|
"/gateway/agents/:id/heartbeat",
|
|
|
|
|
poem::post(gateway_heartbeat_handler),
|
|
|
|
|
)
|
2026-04-28 00:17:44 +00:00
|
|
|
.at(
|
|
|
|
|
"/gateway/events/push",
|
|
|
|
|
poem::get(gateway_event_push_handler),
|
|
|
|
|
)
|
2026-04-14 12:02:17 +00:00
|
|
|
// Serve the embedded React frontend so the gateway has a UI.
|
|
|
|
|
.at(
|
|
|
|
|
"/assets/*path",
|
|
|
|
|
poem::get(crate::http::assets::embedded_asset),
|
|
|
|
|
)
|
|
|
|
|
.at("/*path", poem::get(crate::http::assets::embedded_file))
|
|
|
|
|
.at("/", poem::get(crate::http::assets::embedded_index))
|
2026-04-15 19:50:27 +00:00
|
|
|
.data(state_arc)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Start the gateway HTTP server. This is the entry point when `--gateway` is used.
|
|
|
|
|
pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
|
|
|
|
|
let config_dir = config_path
|
|
|
|
|
.parent()
|
2026-04-24 18:39:16 +00:00
|
|
|
.unwrap_or(std::path::Path::new("."))
|
|
|
|
|
.to_path_buf();
|
2026-04-14 09:57:11 +00:00
|
|
|
|
2026-04-24 18:39:16 +00:00
|
|
|
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);
|
2026-04-14 09:57:11 +00:00
|
|
|
|
2026-04-24 18:39:16 +00:00
|
|
|
let active = state_arc.active_project.read().await.clone();
|
|
|
|
|
crate::slog!("[gateway] Starting gateway on port {port}, active project: {active}");
|
|
|
|
|
crate::slog!(
|
|
|
|
|
"[gateway] Registered projects: {}",
|
|
|
|
|
state_arc
|
|
|
|
|
.projects
|
|
|
|
|
.read()
|
|
|
|
|
.await
|
|
|
|
|
.keys()
|
|
|
|
|
.cloned()
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join(", ")
|
|
|
|
|
);
|
2026-04-14 09:57:11 +00:00
|
|
|
|
2026-04-24 18:39:16 +00:00
|
|
|
// 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}");
|
2026-04-14 09:57:11 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-24 18:39:16 +00:00
|
|
|
// Spawn the Matrix bot if `.huskies/bot.toml` exists in the config directory.
|
|
|
|
|
let gateway_projects: Vec<String> = state_arc.projects.read().await.keys().cloned().collect();
|
|
|
|
|
let gateway_project_urls: std::collections::BTreeMap<String, String> = state_arc
|
|
|
|
|
.projects
|
|
|
|
|
.read()
|
|
|
|
|
.await
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|(name, entry)| (name.clone(), entry.url.clone()))
|
|
|
|
|
.collect();
|
|
|
|
|
let bot_abort = gateway::io::spawn_gateway_bot(
|
|
|
|
|
&config_dir,
|
|
|
|
|
Arc::clone(&state_arc.active_project),
|
|
|
|
|
gateway_projects,
|
|
|
|
|
gateway_project_urls,
|
|
|
|
|
port,
|
2026-04-28 01:27:00 +00:00
|
|
|
Some(state_arc.event_tx.clone()),
|
2026-04-24 18:39:16 +00:00
|
|
|
);
|
|
|
|
|
*state_arc.bot_handle.lock().await = bot_abort;
|
2026-04-14 09:57:11 +00:00
|
|
|
|
2026-04-24 18:39:16 +00:00
|
|
|
let route = build_gateway_route(state_arc);
|
2026-04-14 09:57:11 +00:00
|
|
|
|
2026-04-24 18:39:16 +00:00
|
|
|
let host = std::env::var("HUSKIES_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
|
|
|
|
let addr = format!("{host}:{port}");
|
|
|
|
|
crate::slog!("[gateway] Listening on {addr}");
|
|
|
|
|
|
|
|
|
|
poem::Server::new(poem::listener::TcpListener::bind(&addr))
|
|
|
|
|
.run(route)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
2026-04-14 12:02:17 +00:00
|
|
|
|
2026-04-24 18:39:16 +00:00
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use crate::service::gateway::{GatewayConfig, GatewayState, ProjectEntry};
|
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
|
use std::path::PathBuf;
|
2026-04-14 12:02:17 +00:00
|
|
|
|
|
|
|
|
fn make_test_state() -> Arc<GatewayState> {
|
|
|
|
|
let mut projects = BTreeMap::new();
|
|
|
|
|
projects.insert(
|
|
|
|
|
"test".into(),
|
|
|
|
|
ProjectEntry {
|
|
|
|
|
url: "http://test:3001".into(),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
let config = GatewayConfig { projects };
|
2026-04-14 18:53:41 +00:00
|
|
|
Arc::new(GatewayState::new(config, PathBuf::new(), 3000).unwrap())
|
2026-04-14 12:02:17 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-24 18:39:16 +00:00
|
|
|
#[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.
|
|
|
|
|
|
2026-04-14 12:02:17 +00:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn generate_token_creates_pending_token() {
|
|
|
|
|
let state = make_test_state();
|
|
|
|
|
let app = poem::Route::new()
|
|
|
|
|
.at(
|
|
|
|
|
"/gateway/tokens",
|
|
|
|
|
poem::post(gateway_generate_token_handler),
|
|
|
|
|
)
|
|
|
|
|
.data(state.clone());
|
|
|
|
|
let cli = poem::test::TestClient::new(app);
|
|
|
|
|
let resp = cli.post("/gateway/tokens").send().await;
|
2026-04-24 18:39:16 +00:00
|
|
|
assert_eq!(resp.0.status(), poem::http::StatusCode::OK);
|
|
|
|
|
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
|
2026-04-14 12:02:17 +00:00
|
|
|
let token = body["token"].as_str().unwrap();
|
|
|
|
|
assert!(!token.is_empty());
|
|
|
|
|
let tokens = state.pending_tokens.read().await;
|
|
|
|
|
assert!(tokens.contains_key(token));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn register_agent_consumes_token() {
|
|
|
|
|
let state = make_test_state();
|
|
|
|
|
|
|
|
|
|
let token = "test-token-123".to_string();
|
|
|
|
|
state.pending_tokens.write().await.insert(
|
|
|
|
|
token.clone(),
|
2026-04-24 18:39:16 +00:00
|
|
|
gateway::PendingToken {
|
2026-04-14 12:02:17 +00:00
|
|
|
created_at: chrono::Utc::now().timestamp() as f64,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let app = poem::Route::new()
|
|
|
|
|
.at(
|
|
|
|
|
"/gateway/register",
|
|
|
|
|
poem::post(gateway_register_agent_handler),
|
|
|
|
|
)
|
|
|
|
|
.data(state.clone());
|
|
|
|
|
let cli = poem::test::TestClient::new(app);
|
|
|
|
|
let resp = cli
|
|
|
|
|
.post("/gateway/register")
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
.body(
|
2026-04-24 18:39:16 +00:00
|
|
|
serde_json::json!({
|
2026-04-14 12:02:17 +00:00
|
|
|
"token": token,
|
|
|
|
|
"label": "test-agent",
|
|
|
|
|
"address": "ws://localhost:3001/crdt-sync"
|
|
|
|
|
})
|
|
|
|
|
.to_string(),
|
|
|
|
|
)
|
|
|
|
|
.send()
|
|
|
|
|
.await;
|
2026-04-24 18:39:16 +00:00
|
|
|
assert_eq!(resp.0.status(), poem::http::StatusCode::OK);
|
2026-04-14 12:02:17 +00:00
|
|
|
assert!(state.pending_tokens.read().await.is_empty());
|
|
|
|
|
let agents = state.joined_agents.read().await;
|
|
|
|
|
assert_eq!(agents.len(), 1);
|
|
|
|
|
assert_eq!(agents[0].label, "test-agent");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn register_agent_rejects_invalid_token() {
|
|
|
|
|
let state = make_test_state();
|
|
|
|
|
let app = poem::Route::new()
|
|
|
|
|
.at(
|
|
|
|
|
"/gateway/register",
|
|
|
|
|
poem::post(gateway_register_agent_handler),
|
|
|
|
|
)
|
|
|
|
|
.data(state.clone());
|
|
|
|
|
let cli = poem::test::TestClient::new(app);
|
|
|
|
|
let resp = cli
|
|
|
|
|
.post("/gateway/register")
|
|
|
|
|
.header("Content-Type", "application/json")
|
|
|
|
|
.body(
|
2026-04-24 18:39:16 +00:00
|
|
|
serde_json::json!({
|
2026-04-14 12:02:17 +00:00
|
|
|
"token": "bad-token",
|
|
|
|
|
"label": "agent",
|
|
|
|
|
"address": "ws://localhost:3001/crdt-sync"
|
|
|
|
|
})
|
|
|
|
|
.to_string(),
|
|
|
|
|
)
|
|
|
|
|
.send()
|
|
|
|
|
.await;
|
2026-04-24 18:39:16 +00:00
|
|
|
assert_eq!(resp.0.status(), poem::http::StatusCode::UNAUTHORIZED);
|
2026-04-14 12:02:17 +00:00
|
|
|
assert!(state.joined_agents.read().await.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn list_agents_returns_registered_agents() {
|
|
|
|
|
let state = make_test_state();
|
2026-04-24 18:39:16 +00:00
|
|
|
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,
|
|
|
|
|
});
|
2026-04-14 12:02:17 +00:00
|
|
|
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;
|
2026-04-24 18:39:16 +00:00
|
|
|
assert_eq!(resp.0.status(), poem::http::StatusCode::OK);
|
|
|
|
|
let agents: Vec<serde_json::Value> = resp.0.into_body().into_json().await.unwrap();
|
2026-04-14 12:02:17 +00:00
|
|
|
assert_eq!(agents.len(), 1);
|
|
|
|
|
assert_eq!(agents[0]["label"], "agent-1");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn remove_agent_deletes_by_id() {
|
|
|
|
|
let state = make_test_state();
|
2026-04-24 18:39:16 +00:00
|
|
|
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,
|
|
|
|
|
});
|
2026-04-14 12:02:17 +00:00
|
|
|
let app = poem::Route::new()
|
|
|
|
|
.at(
|
|
|
|
|
"/gateway/agents/:id",
|
|
|
|
|
poem::delete(gateway_remove_agent_handler),
|
|
|
|
|
)
|
|
|
|
|
.data(state.clone());
|
|
|
|
|
let cli = poem::test::TestClient::new(app);
|
|
|
|
|
let resp = cli.delete("/gateway/agents/del-id").send().await;
|
2026-04-24 18:39:16 +00:00
|
|
|
assert_eq!(resp.0.status(), poem::http::StatusCode::NO_CONTENT);
|
2026-04-14 12:02:17 +00:00
|
|
|
assert!(state.joined_agents.read().await.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn remove_agent_unknown_id_returns_not_found() {
|
|
|
|
|
let state = make_test_state();
|
|
|
|
|
let app = poem::Route::new()
|
|
|
|
|
.at(
|
|
|
|
|
"/gateway/agents/:id",
|
|
|
|
|
poem::delete(gateway_remove_agent_handler),
|
|
|
|
|
)
|
|
|
|
|
.data(state.clone());
|
|
|
|
|
let cli = poem::test::TestClient::new(app);
|
|
|
|
|
let resp = cli.delete("/gateway/agents/no-such-id").send().await;
|
2026-04-24 18:39:16 +00:00
|
|
|
assert_eq!(resp.0.status(), poem::http::StatusCode::NOT_FOUND);
|
2026-04-14 12:02:17 +00:00
|
|
|
}
|
2026-04-15 18:20:39 +00:00
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn heartbeat_updates_last_seen() {
|
|
|
|
|
let state = make_test_state();
|
2026-04-24 18:39:16 +00:00
|
|
|
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,
|
|
|
|
|
});
|
2026-04-15 18:20:39 +00:00
|
|
|
let app = poem::Route::new()
|
|
|
|
|
.at(
|
|
|
|
|
"/gateway/agents/:id/heartbeat",
|
|
|
|
|
poem::post(gateway_heartbeat_handler),
|
|
|
|
|
)
|
|
|
|
|
.data(state.clone());
|
|
|
|
|
let cli = poem::test::TestClient::new(app);
|
|
|
|
|
let resp = cli.post("/gateway/agents/hb-id/heartbeat").send().await;
|
2026-04-24 18:39:16 +00:00
|
|
|
assert_eq!(resp.0.status(), poem::http::StatusCode::NO_CONTENT);
|
2026-04-15 18:20:39 +00:00
|
|
|
let agents = state.joined_agents.read().await;
|
|
|
|
|
assert!(agents[0].last_seen > 0.0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn heartbeat_unknown_id_returns_not_found() {
|
|
|
|
|
let state = make_test_state();
|
|
|
|
|
let app = poem::Route::new()
|
|
|
|
|
.at(
|
|
|
|
|
"/gateway/agents/:id/heartbeat",
|
|
|
|
|
poem::post(gateway_heartbeat_handler),
|
|
|
|
|
)
|
|
|
|
|
.data(state.clone());
|
|
|
|
|
let cli = poem::test::TestClient::new(app);
|
|
|
|
|
let resp = cli
|
|
|
|
|
.post("/gateway/agents/no-such-id/heartbeat")
|
|
|
|
|
.send()
|
|
|
|
|
.await;
|
2026-04-24 18:39:16 +00:00
|
|
|
assert_eq!(resp.0.status(), poem::http::StatusCode::NOT_FOUND);
|
2026-04-15 18:20:39 +00:00
|
|
|
}
|
2026-04-15 19:50:27 +00:00
|
|
|
|
2026-04-24 18:39:16 +00:00
|
|
|
// ── Notification poller integration tests ────────────────────────────
|
|
|
|
|
|
2026-04-23 12:05:27 +00:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn gateway_notification_poller_continues_when_one_project_unreachable() {
|
|
|
|
|
use crate::chat::{ChatTransport, MessageId};
|
2026-04-24 18:39:16 +00:00
|
|
|
use crate::service::events::StoredEvent;
|
2026-04-23 12:05:27 +00:00
|
|
|
use async_trait::async_trait;
|
|
|
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
|
|
|
|
|
|
|
|
type CallLog = Arc<std::sync::Mutex<Vec<String>>>;
|
|
|
|
|
|
|
|
|
|
struct MockTransport {
|
|
|
|
|
calls: CallLog,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[async_trait]
|
|
|
|
|
impl ChatTransport for MockTransport {
|
|
|
|
|
async fn send_message(
|
|
|
|
|
&self,
|
|
|
|
|
_room_id: &str,
|
|
|
|
|
plain: &str,
|
|
|
|
|
_html: &str,
|
|
|
|
|
) -> Result<MessageId, String> {
|
|
|
|
|
self.calls.lock().unwrap().push(plain.to_string());
|
|
|
|
|
Ok("id".to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn edit_message(
|
|
|
|
|
&self,
|
|
|
|
|
_room_id: &str,
|
|
|
|
|
_id: &str,
|
|
|
|
|
_plain: &str,
|
|
|
|
|
_html: &str,
|
|
|
|
|
) -> Result<(), String> {
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn send_typing(&self, _room_id: &str, _typing: bool) -> Result<(), String> {
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let calls: CallLog = Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
|
|
|
let transport = Arc::new(MockTransport {
|
|
|
|
|
calls: Arc::clone(&calls),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let event = vec![StoredEvent::StoryBlocked {
|
|
|
|
|
story_id: "10_story_ok".to_string(),
|
|
|
|
|
reason: "retry limit".to_string(),
|
|
|
|
|
timestamp_ms: 500,
|
|
|
|
|
}];
|
|
|
|
|
let event_body = serde_json::to_vec(&event).unwrap();
|
|
|
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
|
|
|
let good_port = listener.local_addr().unwrap().port();
|
|
|
|
|
let good_url = format!("http://127.0.0.1:{good_port}");
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
for _ in 0..4 {
|
|
|
|
|
if let Ok((mut stream, _)) = listener.accept().await {
|
|
|
|
|
let mut buf = vec![0u8; 4096];
|
|
|
|
|
let _ = stream.read(&mut buf).await;
|
|
|
|
|
let header = format!(
|
|
|
|
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
|
|
|
|
event_body.len()
|
|
|
|
|
);
|
|
|
|
|
let _ = stream.write_all(header.as_bytes()).await;
|
|
|
|
|
let _ = stream.write_all(&event_body).await;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
2026-04-24 18:39:16 +00:00
|
|
|
gateway::spawn_gateway_notification_poller(
|
2026-04-23 12:05:27 +00:00
|
|
|
transport as Arc<dyn crate::chat::ChatTransport>,
|
|
|
|
|
vec!["!room:example.org".to_string()],
|
|
|
|
|
project_urls,
|
|
|
|
|
1,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
|
|
|
|
|
|
|
|
|
let messages = calls.lock().unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
!messages.is_empty(),
|
|
|
|
|
"Expected notifications from the reachable project; got none"
|
|
|
|
|
);
|
|
|
|
|
let has_good = messages
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|m| m.contains("[good-project]") && m.contains("10_story_ok"));
|
|
|
|
|
assert!(
|
|
|
|
|
has_good,
|
|
|
|
|
"Expected a notification from [good-project]; got: {messages:?}"
|
|
|
|
|
);
|
|
|
|
|
let has_bad = messages.iter().any(|m| m.contains("[unreachable-project]"));
|
|
|
|
|
assert!(
|
|
|
|
|
!has_bad,
|
|
|
|
|
"Unreachable project must not produce notifications; got: {messages:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn gateway_notification_poller_sends_only_to_configured_gateway_rooms() {
|
|
|
|
|
use crate::chat::{ChatTransport, MessageId};
|
2026-04-24 18:39:16 +00:00
|
|
|
use crate::service::events::StoredEvent;
|
2026-04-23 12:05:27 +00:00
|
|
|
use async_trait::async_trait;
|
|
|
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
|
|
|
|
|
|
|
|
type RoomLog = Arc<std::sync::Mutex<Vec<String>>>;
|
|
|
|
|
|
|
|
|
|
struct RoomCapture {
|
|
|
|
|
rooms: RoomLog,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[async_trait]
|
|
|
|
|
impl ChatTransport for RoomCapture {
|
|
|
|
|
async fn send_message(
|
|
|
|
|
&self,
|
|
|
|
|
room_id: &str,
|
|
|
|
|
_plain: &str,
|
|
|
|
|
_html: &str,
|
|
|
|
|
) -> Result<MessageId, String> {
|
|
|
|
|
self.rooms.lock().unwrap().push(room_id.to_string());
|
|
|
|
|
Ok("id".to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn edit_message(
|
|
|
|
|
&self,
|
|
|
|
|
_room_id: &str,
|
|
|
|
|
_id: &str,
|
|
|
|
|
_plain: &str,
|
|
|
|
|
_html: &str,
|
|
|
|
|
) -> Result<(), String> {
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn send_typing(&self, _room_id: &str, _typing: bool) -> Result<(), String> {
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let rooms: RoomLog = Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
|
|
|
let transport = Arc::new(RoomCapture {
|
|
|
|
|
rooms: Arc::clone(&rooms),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let event = vec![StoredEvent::MergeFailure {
|
|
|
|
|
story_id: "5_story_x".to_string(),
|
|
|
|
|
reason: "conflict".to_string(),
|
|
|
|
|
timestamp_ms: 300,
|
|
|
|
|
}];
|
|
|
|
|
let event_body = serde_json::to_vec(&event).unwrap();
|
|
|
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
|
|
|
let port = listener.local_addr().unwrap().port();
|
|
|
|
|
let url = format!("http://127.0.0.1:{port}");
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
for _ in 0..4 {
|
|
|
|
|
if let Ok((mut stream, _)) = listener.accept().await {
|
|
|
|
|
let mut buf = vec![0u8; 4096];
|
|
|
|
|
let _ = stream.read(&mut buf).await;
|
|
|
|
|
let header = format!(
|
|
|
|
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
|
|
|
|
event_body.len()
|
|
|
|
|
);
|
|
|
|
|
let _ = stream.write_all(header.as_bytes()).await;
|
|
|
|
|
let _ = stream.write_all(&event_body).await;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
|
|
|
|
|
|
|
|
const GATEWAY_ROOM: &str = "!gateway:example.org";
|
2026-04-24 18:39:16 +00:00
|
|
|
#[allow(dead_code)]
|
2026-04-23 12:05:27 +00:00
|
|
|
const PER_PROJECT_ROOM: &str = "!project:example.org";
|
|
|
|
|
|
|
|
|
|
let mut project_urls = BTreeMap::new();
|
|
|
|
|
project_urls.insert("myproj".to_string(), url);
|
|
|
|
|
|
2026-04-24 18:39:16 +00:00
|
|
|
gateway::spawn_gateway_notification_poller(
|
2026-04-23 12:05:27 +00:00
|
|
|
transport as Arc<dyn crate::chat::ChatTransport>,
|
|
|
|
|
vec![GATEWAY_ROOM.to_string()],
|
|
|
|
|
project_urls,
|
|
|
|
|
1,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
|
|
|
|
|
|
|
|
|
let room_calls = rooms.lock().unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
!room_calls.is_empty(),
|
|
|
|
|
"Expected at least one notification; got none"
|
|
|
|
|
);
|
|
|
|
|
for room in room_calls.iter() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
room, GATEWAY_ROOM,
|
|
|
|
|
"Notification must only go to the gateway room, not {room}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
assert!(
|
|
|
|
|
!room_calls.iter().any(|r| r == PER_PROJECT_ROOM),
|
|
|
|
|
"Per-project room must not receive gateway aggregated notifications"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 18:39:16 +00:00
|
|
|
// ── init_project integration tests ──────────────────────────────────
|
2026-04-22 21:33:44 +00:00
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn init_project_scaffolds_huskies_dir() {
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
let state = make_test_state();
|
2026-04-24 18:39:16 +00:00
|
|
|
let result = gateway::init_project(&state, dir.path().to_str().unwrap(), None, None).await;
|
2026-04-22 21:33:44 +00:00
|
|
|
assert!(
|
2026-04-24 18:39:16 +00:00
|
|
|
result.is_ok(),
|
2026-04-22 21:33:44 +00:00
|
|
|
"init_project should succeed: {:?}",
|
2026-04-24 18:39:16 +00:00
|
|
|
result.err()
|
2026-04-22 21:33:44 +00:00
|
|
|
);
|
2026-04-24 18:39:16 +00:00
|
|
|
assert!(dir.path().join(".huskies").exists());
|
2026-04-22 21:33:44 +00:00
|
|
|
assert!(dir.path().join(".huskies/project.toml").exists());
|
|
|
|
|
assert!(dir.path().join(".huskies/agents.toml").exists());
|
|
|
|
|
assert!(dir.path().join("script/test").exists());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn init_project_creates_wizard_state() {
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
let state = make_test_state();
|
2026-04-24 18:39:16 +00:00
|
|
|
gateway::init_project(&state, dir.path().to_str().unwrap(), None, None)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
2026-04-22 21:33:44 +00:00
|
|
|
let wizard_state_path = dir.path().join(".huskies/wizard_state.json");
|
2026-04-24 18:39:16 +00:00
|
|
|
assert!(wizard_state_path.exists());
|
2026-04-22 21:33:44 +00:00
|
|
|
let content = std::fs::read_to_string(&wizard_state_path).unwrap();
|
2026-04-24 18:39:16 +00:00
|
|
|
let v: serde_json::Value = serde_json::from_str(&content).unwrap();
|
|
|
|
|
assert!(v.get("steps").is_some());
|
|
|
|
|
assert!(v.get("completed").is_some());
|
2026-04-22 21:33:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn init_project_already_initialised_returns_error() {
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
|
|
|
|
|
let state = make_test_state();
|
2026-04-24 18:39:16 +00:00
|
|
|
let result = gateway::init_project(&state, dir.path().to_str().unwrap(), None, None).await;
|
|
|
|
|
assert!(result.is_err());
|
2026-04-22 21:33:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn init_project_missing_path_returns_error() {
|
|
|
|
|
let state = make_test_state();
|
2026-04-24 18:39:16 +00:00
|
|
|
let result = gateway::init_project(&state, "", None, None).await;
|
|
|
|
|
assert!(result.is_err());
|
2026-04-22 21:33:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn init_project_registers_in_projects_toml_when_name_and_url_given() {
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
let config_dir = tempfile::tempdir().unwrap();
|
|
|
|
|
let mut projects = BTreeMap::new();
|
|
|
|
|
projects.insert(
|
|
|
|
|
"existing".into(),
|
|
|
|
|
ProjectEntry {
|
|
|
|
|
url: "http://existing:3001".into(),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
let config = GatewayConfig { projects };
|
|
|
|
|
let state =
|
|
|
|
|
Arc::new(GatewayState::new(config, config_dir.path().to_path_buf(), 3000).unwrap());
|
|
|
|
|
|
2026-04-24 18:39:16 +00:00
|
|
|
let result = gateway::init_project(
|
|
|
|
|
&state,
|
|
|
|
|
dir.path().to_str().unwrap(),
|
|
|
|
|
Some("new-project"),
|
|
|
|
|
Some("http://new-project:3002"),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
assert!(result.is_ok());
|
2026-04-22 21:33:44 +00:00
|
|
|
|
|
|
|
|
let projects = state.projects.read().await;
|
2026-04-24 18:39:16 +00:00
|
|
|
assert!(projects.contains_key("new-project"));
|
2026-04-22 21:33:44 +00:00
|
|
|
assert_eq!(projects["new-project"].url, "http://new-project:3002");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn init_project_duplicate_name_returns_error() {
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
let mut projects = BTreeMap::new();
|
|
|
|
|
projects.insert(
|
|
|
|
|
"taken".into(),
|
|
|
|
|
ProjectEntry {
|
|
|
|
|
url: "http://taken:3001".into(),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
let config = GatewayConfig { projects };
|
|
|
|
|
let state = Arc::new(GatewayState::new(config, PathBuf::new(), 3000).unwrap());
|
|
|
|
|
|
2026-04-24 18:39:16 +00:00
|
|
|
let result = gateway::init_project(
|
|
|
|
|
&state,
|
|
|
|
|
dir.path().to_str().unwrap(),
|
|
|
|
|
Some("taken"),
|
|
|
|
|
Some("http://new:3002"),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
assert!(result.is_err());
|
2026-04-22 21:33:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn init_project_then_wizard_status_integration() {
|
|
|
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
|
|
|
|
|
|
|
|
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}");
|
|
|
|
|
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
if let Ok((mut stream, _)) = listener.accept().await {
|
|
|
|
|
let mut buf = vec![0u8; 4096];
|
|
|
|
|
let _ = stream.read(&mut buf).await;
|
|
|
|
|
let body = serde_json::json!({
|
|
|
|
|
"jsonrpc": "2.0",
|
|
|
|
|
"id": 1,
|
|
|
|
|
"result": {
|
|
|
|
|
"content": [{
|
|
|
|
|
"type": "text",
|
|
|
|
|
"text": "{\"steps\":[{\"id\":\"scaffold\",\"title\":\"Scaffold\",\"status\":\"confirmed\"}],\"completed\":false}"
|
|
|
|
|
}]
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
let body_bytes = serde_json::to_vec(&body).unwrap();
|
|
|
|
|
let header = format!(
|
|
|
|
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
|
|
|
|
body_bytes.len()
|
|
|
|
|
);
|
|
|
|
|
let _ = stream.write_all(header.as_bytes()).await;
|
|
|
|
|
let _ = stream.write_all(&body_bytes).await;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
|
|
|
|
|
|
|
|
let mut projects = BTreeMap::new();
|
|
|
|
|
projects.insert("mock-project".into(), ProjectEntry { url: mock_url });
|
|
|
|
|
let config = GatewayConfig { projects };
|
|
|
|
|
let config_dir = tempfile::tempdir().unwrap();
|
|
|
|
|
let state =
|
|
|
|
|
Arc::new(GatewayState::new(config, config_dir.path().to_path_buf(), 3000).unwrap());
|
|
|
|
|
|
|
|
|
|
let project_dir = tempfile::tempdir().unwrap();
|
2026-04-24 18:39:16 +00:00
|
|
|
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());
|
2026-04-22 21:33:44 +00:00
|
|
|
|
|
|
|
|
let wizard_path = project_dir.path().join(".huskies/wizard_state.json");
|
2026-04-24 18:39:16 +00:00
|
|
|
assert!(wizard_path.exists());
|
2026-04-22 21:33:44 +00:00
|
|
|
|
2026-04-24 18:39:16 +00:00
|
|
|
// Proxy call to the mock server.
|
|
|
|
|
let active_url = state.active_url().await.unwrap();
|
|
|
|
|
let proxy_body = serde_json::to_vec(&serde_json::json!({
|
2026-04-22 21:33:44 +00:00
|
|
|
"jsonrpc": "2.0",
|
|
|
|
|
"id": 2,
|
|
|
|
|
"method": "tools/call",
|
|
|
|
|
"params": { "name": "wizard_status", "arguments": {} }
|
|
|
|
|
}))
|
|
|
|
|
.unwrap();
|
2026-04-24 18:39:16 +00:00
|
|
|
let proxy_resp = gateway::io::proxy_mcp_call(&state.client, &active_url, &proxy_body).await;
|
|
|
|
|
assert!(proxy_resp.is_ok());
|
2026-04-22 21:33:44 +00:00
|
|
|
|
2026-04-24 18:39:16 +00:00
|
|
|
let resp_json: serde_json::Value = serde_json::from_slice(&proxy_resp.unwrap()).unwrap();
|
2026-04-22 21:33:44 +00:00
|
|
|
let result = resp_json.get("result");
|
2026-04-24 18:39:16 +00:00
|
|
|
assert!(result.is_some());
|
2026-04-22 21:33:44 +00:00
|
|
|
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("");
|
2026-04-24 18:39:16 +00:00
|
|
|
let wizard: serde_json::Value = serde_json::from_str(text).unwrap();
|
|
|
|
|
assert!(wizard.get("steps").is_some());
|
2026-04-23 10:38:30 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-24 18:39:16 +00:00
|
|
|
// ── Aggregate pipeline status integration tests ─────────────────────
|
2026-04-23 10:38:30 +00:00
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn aggregate_pipeline_status_integration_healthy_and_unreachable() {
|
|
|
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
|
|
|
|
|
|
|
|
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}");
|
|
|
|
|
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
if let Ok((mut stream, _)) = listener.accept().await {
|
|
|
|
|
let mut buf = vec![0u8; 4096];
|
|
|
|
|
let _ = stream.read(&mut buf).await;
|
2026-04-24 18:39:16 +00:00
|
|
|
let pipeline_json = serde_json::to_string(&serde_json::json!({
|
2026-04-23 10:38:30 +00:00
|
|
|
"active": [
|
|
|
|
|
{ "story_id": "1_story_a", "name": "A", "stage": "current" },
|
|
|
|
|
{ "story_id": "2_story_b", "name": "B", "stage": "qa" },
|
|
|
|
|
{ "story_id": "3_story_c", "name": "C", "stage": "current", "blocked": true, "retry_count": 5 },
|
|
|
|
|
],
|
|
|
|
|
"backlog": [{ "story_id": "4_story_d", "name": "D" }],
|
|
|
|
|
"backlog_count": 1
|
2026-04-24 18:39:16 +00:00
|
|
|
}))
|
|
|
|
|
.unwrap();
|
|
|
|
|
let body = serde_json::to_vec(&serde_json::json!({
|
2026-04-23 10:38:30 +00:00
|
|
|
"jsonrpc": "2.0",
|
|
|
|
|
"id": 1,
|
|
|
|
|
"result": {
|
|
|
|
|
"content": [{ "type": "text", "text": pipeline_json }]
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
.unwrap();
|
|
|
|
|
let header = format!(
|
|
|
|
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
|
|
|
|
body.len()
|
|
|
|
|
);
|
|
|
|
|
let _ = stream.write_all(header.as_bytes()).await;
|
|
|
|
|
let _ = stream.write_all(&body).await;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
|
|
|
|
2026-04-24 18:39:16 +00:00
|
|
|
let unreachable_url = "http://127.0.0.1:1".to_string();
|
2026-04-23 10:38:30 +00:00
|
|
|
|
|
|
|
|
let mut project_urls = BTreeMap::new();
|
|
|
|
|
project_urls.insert("healthy-project".to_string(), healthy_url);
|
|
|
|
|
project_urls.insert("broken-project".to_string(), unreachable_url);
|
|
|
|
|
|
2026-04-24 18:39:16 +00:00
|
|
|
let client = reqwest::Client::new();
|
|
|
|
|
let statuses = gateway::fetch_all_project_pipeline_statuses(&project_urls, &client).await;
|
2026-04-23 10:38:30 +00:00
|
|
|
|
2026-04-24 18:39:16 +00:00
|
|
|
assert!(statuses.contains_key("healthy-project"));
|
|
|
|
|
assert!(statuses.contains_key("broken-project"));
|
2026-04-23 10:38:30 +00:00
|
|
|
|
|
|
|
|
let healthy = &statuses["healthy-project"];
|
2026-04-24 18:39:16 +00:00
|
|
|
assert!(healthy.get("error").is_none());
|
2026-04-23 10:38:30 +00:00
|
|
|
assert_eq!(healthy["counts"]["backlog"], 1);
|
|
|
|
|
assert_eq!(healthy["counts"]["current"], 2);
|
|
|
|
|
assert_eq!(healthy["counts"]["qa"], 1);
|
|
|
|
|
|
|
|
|
|
let blocked = healthy["blocked"].as_array().unwrap();
|
2026-04-24 18:39:16 +00:00
|
|
|
assert_eq!(blocked.len(), 1);
|
2026-04-23 10:38:30 +00:00
|
|
|
assert_eq!(blocked[0]["story_id"], "3_story_c");
|
|
|
|
|
|
|
|
|
|
let broken = &statuses["broken-project"];
|
2026-04-24 18:39:16 +00:00
|
|
|
assert!(broken.get("error").is_some());
|
2026-04-23 12:05:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-24 18:39:16 +00:00
|
|
|
// ── Multi-project notification poller integration ────────────────────
|
2026-04-23 12:05:27 +00:00
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn gateway_notification_poller_delivers_events_from_two_projects_with_project_tags() {
|
|
|
|
|
use crate::chat::{ChatTransport, MessageId};
|
2026-04-24 18:39:16 +00:00
|
|
|
use crate::service::events::StoredEvent;
|
2026-04-23 12:05:27 +00:00
|
|
|
use async_trait::async_trait;
|
|
|
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
|
|
|
|
|
|
|
|
type CallLog = Arc<std::sync::Mutex<Vec<(String, String, String)>>>;
|
|
|
|
|
|
|
|
|
|
struct MockTransport {
|
|
|
|
|
calls: CallLog,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[async_trait]
|
|
|
|
|
impl ChatTransport for MockTransport {
|
|
|
|
|
async fn send_message(
|
|
|
|
|
&self,
|
|
|
|
|
room_id: &str,
|
|
|
|
|
plain: &str,
|
|
|
|
|
html: &str,
|
|
|
|
|
) -> Result<MessageId, String> {
|
|
|
|
|
self.calls.lock().unwrap().push((
|
|
|
|
|
room_id.to_string(),
|
|
|
|
|
plain.to_string(),
|
|
|
|
|
html.to_string(),
|
|
|
|
|
));
|
|
|
|
|
Ok("mock-id".to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn edit_message(
|
|
|
|
|
&self,
|
|
|
|
|
_room_id: &str,
|
|
|
|
|
_id: &str,
|
|
|
|
|
_plain: &str,
|
|
|
|
|
_html: &str,
|
|
|
|
|
) -> Result<(), String> {
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn send_typing(&self, _room_id: &str, _typing: bool) -> Result<(), String> {
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let calls: CallLog = Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
|
|
|
let transport = Arc::new(MockTransport {
|
|
|
|
|
calls: Arc::clone(&calls),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let alpha_events = vec![StoredEvent::StageTransition {
|
|
|
|
|
story_id: "1_story_alpha".to_string(),
|
|
|
|
|
from_stage: "2_current".to_string(),
|
|
|
|
|
to_stage: "3_qa".to_string(),
|
|
|
|
|
timestamp_ms: 100,
|
|
|
|
|
}];
|
|
|
|
|
let alpha_body = serde_json::to_vec(&alpha_events).unwrap();
|
|
|
|
|
let alpha_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
|
|
|
let alpha_port = alpha_listener.local_addr().unwrap().port();
|
|
|
|
|
let alpha_url = format!("http://127.0.0.1:{alpha_port}");
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
for _ in 0..4 {
|
|
|
|
|
if let Ok((mut stream, _)) = alpha_listener.accept().await {
|
|
|
|
|
let mut buf = vec![0u8; 4096];
|
|
|
|
|
let _ = stream.read(&mut buf).await;
|
|
|
|
|
let header = format!(
|
|
|
|
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
|
|
|
|
alpha_body.len()
|
|
|
|
|
);
|
|
|
|
|
let _ = stream.write_all(header.as_bytes()).await;
|
|
|
|
|
let _ = stream.write_all(&alpha_body).await;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let beta_events = vec![StoredEvent::MergeFailure {
|
|
|
|
|
story_id: "2_story_beta".to_string(),
|
|
|
|
|
reason: "merge conflict in lib.rs".to_string(),
|
|
|
|
|
timestamp_ms: 200,
|
|
|
|
|
}];
|
|
|
|
|
let beta_body = serde_json::to_vec(&beta_events).unwrap();
|
|
|
|
|
let beta_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
|
|
|
let beta_port = beta_listener.local_addr().unwrap().port();
|
|
|
|
|
let beta_url = format!("http://127.0.0.1:{beta_port}");
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
for _ in 0..4 {
|
|
|
|
|
if let Ok((mut stream, _)) = beta_listener.accept().await {
|
|
|
|
|
let mut buf = vec![0u8; 4096];
|
|
|
|
|
let _ = stream.read(&mut buf).await;
|
|
|
|
|
let header = format!(
|
|
|
|
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
|
|
|
|
beta_body.len()
|
|
|
|
|
);
|
|
|
|
|
let _ = stream.write_all(header.as_bytes()).await;
|
|
|
|
|
let _ = stream.write_all(&beta_body).await;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
|
|
|
|
|
|
|
|
let mut project_urls = BTreeMap::new();
|
|
|
|
|
project_urls.insert("alpha".to_string(), alpha_url);
|
|
|
|
|
project_urls.insert("beta".to_string(), beta_url);
|
|
|
|
|
|
2026-04-24 18:39:16 +00:00
|
|
|
gateway::spawn_gateway_notification_poller(
|
2026-04-23 12:05:27 +00:00
|
|
|
transport as Arc<dyn crate::chat::ChatTransport>,
|
|
|
|
|
vec!["!room:example.org".to_string()],
|
|
|
|
|
project_urls,
|
2026-04-24 18:39:16 +00:00
|
|
|
1,
|
2026-04-23 12:05:27 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
|
|
|
|
|
|
|
|
|
let calls = calls.lock().unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
!calls.is_empty(),
|
|
|
|
|
"Expected at least one notification; got none"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let plains: Vec<&str> = calls.iter().map(|(_, p, _)| p.as_str()).collect();
|
|
|
|
|
|
|
|
|
|
let alpha_notification = plains
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|p| p.contains("[alpha]") && p.contains("1"));
|
|
|
|
|
let beta_notification = plains
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|p| p.contains("[beta]") && p.contains("merge conflict"));
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
alpha_notification,
|
|
|
|
|
"Expected a notification from [alpha] containing story ID '1'; got: {plains:?}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
beta_notification,
|
|
|
|
|
"Expected a notification from [beta] containing 'merge conflict'; got: {plains:?}"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
for (room_id, _, _) in calls.iter() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
room_id, "!room:example.org",
|
|
|
|
|
"All notifications must go to the gateway room"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-24 18:39:16 +00:00
|
|
|
|
2026-04-28 01:27:00 +00:00
|
|
|
// ── Gateway broadcaster forwarder tests ─────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn broadcaster_forwarder_forwards_events_with_project_tag() {
|
|
|
|
|
use crate::chat::{ChatTransport, MessageId};
|
|
|
|
|
use crate::service::events::StoredEvent;
|
|
|
|
|
use async_trait::async_trait;
|
|
|
|
|
|
|
|
|
|
type CallLog = Arc<std::sync::Mutex<Vec<(String, String)>>>;
|
|
|
|
|
|
|
|
|
|
struct MockTransport {
|
|
|
|
|
calls: CallLog,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[async_trait]
|
|
|
|
|
impl ChatTransport for MockTransport {
|
|
|
|
|
async fn send_message(
|
|
|
|
|
&self,
|
|
|
|
|
room_id: &str,
|
|
|
|
|
plain: &str,
|
|
|
|
|
_html: &str,
|
|
|
|
|
) -> Result<MessageId, String> {
|
|
|
|
|
self.calls
|
|
|
|
|
.lock()
|
|
|
|
|
.unwrap()
|
|
|
|
|
.push((room_id.to_string(), plain.to_string()));
|
|
|
|
|
Ok("id".to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn edit_message(
|
|
|
|
|
&self,
|
|
|
|
|
_room_id: &str,
|
|
|
|
|
_id: &str,
|
|
|
|
|
_plain: &str,
|
|
|
|
|
_html: &str,
|
|
|
|
|
) -> Result<(), String> {
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn send_typing(&self, _room_id: &str, _typing: bool) -> Result<(), String> {
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let calls: CallLog = Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
|
|
|
let transport = Arc::new(MockTransport {
|
|
|
|
|
calls: Arc::clone(&calls),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let (tx, rx) =
|
|
|
|
|
tokio::sync::broadcast::channel::<crate::service::gateway::GatewayStatusEvent>(16);
|
|
|
|
|
gateway::spawn_gateway_broadcaster_forwarder(
|
|
|
|
|
transport as Arc<dyn crate::chat::ChatTransport>,
|
|
|
|
|
vec!["!room:example.org".to_string()],
|
|
|
|
|
rx,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Give the forwarder task a moment to start.
|
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
|
|
|
|
|
|
|
|
let event = crate::service::gateway::GatewayStatusEvent {
|
|
|
|
|
project: "my-project".to_string(),
|
|
|
|
|
event: StoredEvent::StageTransition {
|
|
|
|
|
story_id: "7_story_x".to_string(),
|
|
|
|
|
from_stage: "2_current".to_string(),
|
|
|
|
|
to_stage: "3_qa".to_string(),
|
|
|
|
|
timestamp_ms: 100,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
tx.send(event).unwrap();
|
|
|
|
|
|
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
|
|
|
|
|
|
|
|
|
let messages = calls.lock().unwrap();
|
|
|
|
|
assert_eq!(messages.len(), 1, "Expected exactly one notification");
|
|
|
|
|
let (room, plain) = &messages[0];
|
|
|
|
|
assert_eq!(room, "!room:example.org");
|
|
|
|
|
assert!(
|
|
|
|
|
plain.starts_with("[my-project]"),
|
|
|
|
|
"Expected [my-project] prefix; got: {plain}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
plain.contains("7_story_x"),
|
|
|
|
|
"Expected story ID; got: {plain}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn broadcaster_forwarder_resubscribes_on_lag() {
|
|
|
|
|
use crate::chat::{ChatTransport, MessageId};
|
|
|
|
|
use crate::service::events::StoredEvent;
|
|
|
|
|
use async_trait::async_trait;
|
|
|
|
|
|
|
|
|
|
type Counter = Arc<std::sync::Mutex<usize>>;
|
|
|
|
|
|
|
|
|
|
struct CountTransport {
|
|
|
|
|
count: Counter,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[async_trait]
|
|
|
|
|
impl ChatTransport for CountTransport {
|
|
|
|
|
async fn send_message(
|
|
|
|
|
&self,
|
|
|
|
|
_room_id: &str,
|
|
|
|
|
_plain: &str,
|
|
|
|
|
_html: &str,
|
|
|
|
|
) -> Result<MessageId, String> {
|
|
|
|
|
*self.count.lock().unwrap() += 1;
|
|
|
|
|
Ok("id".to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn edit_message(
|
|
|
|
|
&self,
|
|
|
|
|
_room_id: &str,
|
|
|
|
|
_id: &str,
|
|
|
|
|
_plain: &str,
|
|
|
|
|
_html: &str,
|
|
|
|
|
) -> Result<(), String> {
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn send_typing(&self, _room_id: &str, _typing: bool) -> Result<(), String> {
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let count: Counter = Arc::new(std::sync::Mutex::new(0));
|
|
|
|
|
let transport = Arc::new(CountTransport {
|
|
|
|
|
count: Arc::clone(&count),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Use a tiny channel (capacity 1) so the second send causes a Lagged error.
|
|
|
|
|
let (tx, rx) =
|
|
|
|
|
tokio::sync::broadcast::channel::<crate::service::gateway::GatewayStatusEvent>(1);
|
|
|
|
|
|
|
|
|
|
// Flood the channel to trigger Lagged before the forwarder task starts.
|
|
|
|
|
let make_event = |n: u64| crate::service::gateway::GatewayStatusEvent {
|
|
|
|
|
project: "p".to_string(),
|
|
|
|
|
event: StoredEvent::StageTransition {
|
|
|
|
|
story_id: format!("{n}_story"),
|
|
|
|
|
from_stage: "2_current".to_string(),
|
|
|
|
|
to_stage: "3_qa".to_string(),
|
|
|
|
|
timestamp_ms: n,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
// Send 3 events to overflow the capacity-1 channel before the task runs.
|
|
|
|
|
let _ = tx.send(make_event(1));
|
|
|
|
|
let _ = tx.send(make_event(2));
|
|
|
|
|
let _ = tx.send(make_event(3));
|
|
|
|
|
|
|
|
|
|
gateway::spawn_gateway_broadcaster_forwarder(
|
|
|
|
|
transport as Arc<dyn crate::chat::ChatTransport>,
|
|
|
|
|
vec!["!r:x.org".to_string()],
|
|
|
|
|
rx,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Send one more event after the forwarder subscribes; it should arrive.
|
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
|
|
|
|
tx.send(make_event(4)).unwrap();
|
|
|
|
|
|
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
|
|
|
|
|
|
|
|
|
// After Lagged + resubscribe, the forwarder must still process event 4.
|
|
|
|
|
let received = *count.lock().unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
received >= 1,
|
|
|
|
|
"Expected at least one event after Lagged resubscribe; got {received}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 18:39:16 +00:00
|
|
|
// ── 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());
|
|
|
|
|
}
|
2026-04-13 13:02:41 +00:00
|
|
|
}
|