2026-04-29 00:29:54 +00:00
|
|
|
//! Multi-project gateway — entrypoint wiring and route tree.
|
|
|
|
|
//!
|
|
|
|
|
//! 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::*;
|
2026-05-12 16:30:55 +00:00
|
|
|
use crate::rebuild::ShutdownReason;
|
2026-04-29 00:29:54 +00:00
|
|
|
use crate::service::gateway::{self, GatewayState};
|
|
|
|
|
use poem::EndpointExt;
|
|
|
|
|
use std::path::Path;
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
|
|
|
|
// Re-export public types that callers reference as `crate::gateway::*`.
|
|
|
|
|
pub use crate::service::gateway::{
|
|
|
|
|
GatewayConfig, GatewayState as GatewayStateType, GatewayStatusEvent, ProjectEntry,
|
|
|
|
|
broadcast_status_event, fetch_all_project_pipeline_statuses, format_aggregate_status_compact,
|
|
|
|
|
spawn_gateway_broadcaster_forwarder, spawn_gateway_notification_poller,
|
|
|
|
|
subscribe_status_events,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/// 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()
|
|
|
|
|
.at("/bot-config", poem::get(gateway_bot_config_page_handler))
|
|
|
|
|
.at("/api/gateway", poem::get(gateway_api_handler))
|
|
|
|
|
.at(
|
|
|
|
|
"/api/gateway/projects",
|
|
|
|
|
poem::post(gateway_add_project_handler),
|
|
|
|
|
)
|
|
|
|
|
.at(
|
|
|
|
|
"/api/gateway/projects/:name",
|
|
|
|
|
poem::delete(gateway_remove_project_handler),
|
|
|
|
|
)
|
|
|
|
|
.at(
|
|
|
|
|
"/api/gateway/bot-config",
|
|
|
|
|
poem::get(gateway_bot_config_get_handler).post(gateway_bot_config_save_handler),
|
|
|
|
|
)
|
|
|
|
|
.at(
|
|
|
|
|
"/mcp",
|
|
|
|
|
poem::post(gateway_mcp_post_handler).get(gateway_mcp_get_handler),
|
|
|
|
|
)
|
|
|
|
|
// Agent join endpoints.
|
|
|
|
|
.at("/gateway/mode", poem::get(gateway_mode_handler))
|
|
|
|
|
.at(
|
|
|
|
|
"/gateway/tokens",
|
|
|
|
|
poem::post(gateway_generate_token_handler),
|
|
|
|
|
)
|
|
|
|
|
.at(
|
|
|
|
|
"/gateway/events/push",
|
|
|
|
|
poem::get(gateway_event_push_handler),
|
|
|
|
|
)
|
|
|
|
|
// Agent registration via CRDT-sync WebSocket.
|
|
|
|
|
.at("/crdt-sync", poem::get(gateway_crdt_sync_handler))
|
2026-05-12 21:29:04 +00:00
|
|
|
// Sled uplink: permission-forwarding WebSocket from sleds to gateway.
|
|
|
|
|
.at("/api/sled-uplink", poem::get(gateway_sled_uplink_handler))
|
2026-04-29 00:29:54 +00:00
|
|
|
// Agent management REST endpoints.
|
|
|
|
|
.at(
|
|
|
|
|
"/gateway/agents/:id/assign",
|
|
|
|
|
poem::post(gateway_assign_agent_handler),
|
|
|
|
|
)
|
|
|
|
|
// Serve the embedded React frontend so the gateway has a UI.
|
|
|
|
|
.at(
|
|
|
|
|
"/assets/*path",
|
|
|
|
|
poem::get(crate::http::assets::embedded_asset),
|
|
|
|
|
)
|
|
|
|
|
.at("/*path", poem::get(crate::http::assets::embedded_file))
|
|
|
|
|
.at("/", poem::get(crate::http::assets::embedded_index))
|
|
|
|
|
.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()
|
|
|
|
|
.unwrap_or(std::path::Path::new("."))
|
|
|
|
|
.to_path_buf();
|
|
|
|
|
|
|
|
|
|
let config = gateway::io::load_config(config_path).map_err(std::io::Error::other)?;
|
|
|
|
|
|
|
|
|
|
// Initialise the CRDT so gateway_config.active_project is persisted across restarts.
|
|
|
|
|
let crdt_db = config_dir.join("gateway.db");
|
|
|
|
|
if let Err(e) = crate::crdt_state::init(&crdt_db).await {
|
|
|
|
|
crate::slog!(
|
|
|
|
|
"[gateway] Warning: CRDT init failed ({e}); active-project selection will not persist"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let state =
|
|
|
|
|
GatewayState::new(config, config_dir.clone(), port).map_err(std::io::Error::other)?;
|
|
|
|
|
let state_arc = Arc::new(state);
|
|
|
|
|
|
|
|
|
|
let active = state_arc.active_project.read().await.clone();
|
|
|
|
|
crate::slog!("[gateway] Starting gateway on port {port}, active project: {active}");
|
|
|
|
|
crate::slog!(
|
|
|
|
|
"[gateway] Registered projects: {}",
|
|
|
|
|
state_arc
|
|
|
|
|
.projects
|
|
|
|
|
.read()
|
|
|
|
|
.await
|
|
|
|
|
.keys()
|
|
|
|
|
.cloned()
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join(", ")
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 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}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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()
|
2026-05-12 23:11:34 +00:00
|
|
|
.filter_map(|(name, entry)| entry.url.as_ref().map(|u| (name.clone(), u.clone())))
|
2026-04-29 00:29:54 +00:00
|
|
|
.collect();
|
2026-05-12 16:30:55 +00:00
|
|
|
let (bot_abort, bot_shutdown_tx) = gateway::io::spawn_gateway_bot(
|
2026-04-29 00:29:54 +00:00
|
|
|
&config_dir,
|
|
|
|
|
Arc::clone(&state_arc.active_project),
|
|
|
|
|
gateway_projects,
|
|
|
|
|
gateway_project_urls,
|
|
|
|
|
port,
|
|
|
|
|
Some(state_arc.event_tx.clone()),
|
2026-05-12 21:29:04 +00:00
|
|
|
Arc::clone(&state_arc.perm_rx),
|
2026-04-29 00:29:54 +00:00
|
|
|
);
|
|
|
|
|
*state_arc.bot_handle.lock().await = bot_abort;
|
2026-05-12 16:30:55 +00:00
|
|
|
*state_arc.bot_shutdown_tx.lock().await = Some(bot_shutdown_tx);
|
2026-04-29 00:29:54 +00:00
|
|
|
|
2026-05-12 16:30:55 +00:00
|
|
|
let route = build_gateway_route(state_arc.clone());
|
2026-04-29 00:29:54 +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}");
|
|
|
|
|
|
2026-05-12 16:30:55 +00:00
|
|
|
let result = poem::Server::new(poem::listener::TcpListener::bind(&addr))
|
2026-04-29 00:29:54 +00:00
|
|
|
.run(route)
|
2026-05-12 16:30:55 +00:00
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
// Best-effort shutdown notification: signal the Matrix bot so it can post
|
|
|
|
|
// "going offline" before the process exits. Mirror of main.rs:346.
|
|
|
|
|
{
|
|
|
|
|
let guard = state_arc.bot_shutdown_tx.lock().await;
|
|
|
|
|
if let Some(tx) = guard.as_ref() {
|
|
|
|
|
let _ = tx.send(Some(ShutdownReason::Manual));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
|
|
|
|
|
|
|
|
|
result
|
2026-04-29 00:29:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests;
|