From 52b21c22b1e871483286c504530e005c72a7e934 Mon Sep 17 00:00:00 2001 From: dave Date: Tue, 14 Apr 2026 18:53:41 +0000 Subject: [PATCH] huskies: merge 566_story_gateway_ui_bot_configuration_page --- server/src/chat/transport/matrix/mod.rs | 13 +- server/src/gateway.rs | 384 +++++++++++++++++++++++- server/src/main.rs | 2 +- 3 files changed, 382 insertions(+), 17 deletions(-) diff --git a/server/src/chat/transport/matrix/mod.rs b/server/src/chat/transport/matrix/mod.rs index 68dfafb8..b9fd18fd 100644 --- a/server/src/chat/transport/matrix/mod.rs +++ b/server/src/chat/transport/matrix/mod.rs @@ -58,6 +58,10 @@ use tokio::sync::{Mutex as TokioMutex, RwLock, broadcast, mpsc, watch}; /// announce the shutdown to all configured rooms before the process exits. /// /// Must be called from within a Tokio runtime context (e.g., from `main`). +/// +/// Returns an [`tokio::task::AbortHandle`] if the bot was actually spawned (Matrix/Discord +/// transports), or `None` if the config is absent, disabled, or uses a webhook-based +/// transport (Slack/WhatsApp) that does not require a persistent background task. pub fn spawn_bot( project_root: &Path, watcher_tx: broadcast::Sender, @@ -66,12 +70,12 @@ pub fn spawn_bot( shutdown_rx: watch::Receiver>, gateway_active_project: Option>>, gateway_projects: Vec, -) { +) -> Option { let config = match BotConfig::load(project_root) { Some(c) => c, None => { crate::slog!("[matrix-bot] bot.toml absent or disabled; Matrix integration skipped"); - return; + return None; } }; @@ -81,7 +85,7 @@ pub fn spawn_bot( "[bot] transport={} β€” skipping Matrix bot; webhooks handle this transport", config.transport ); - return; + return None; } crate::slog!( @@ -93,7 +97,7 @@ pub fn spawn_bot( let root = project_root.to_path_buf(); let watcher_rx = watcher_tx.subscribe(); let watcher_rx_auto = watcher_tx.subscribe(); - tokio::spawn(async move { + let handle = tokio::spawn(async move { if let Err(e) = bot::run_bot( config, root, @@ -110,4 +114,5 @@ pub fn spawn_bot( crate::slog!("[matrix-bot] Fatal error: {e}"); } }); + Some(handle.abort_handle()) } diff --git a/server/src/gateway.rs b/server/src/gateway.rs index 84ce4acf..d5f2a355 100644 --- a/server/src/gateway.rs +++ b/server/src/gateway.rs @@ -19,6 +19,7 @@ use std::collections::BTreeMap; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; +use tokio::sync::Mutex as TokioMutex; use tokio::sync::RwLock; use uuid::Uuid; @@ -106,8 +107,13 @@ pub struct GatewayState { pub joined_agents: Arc>>, /// One-time join tokens that have been issued but not yet consumed. pending_tokens: Arc>>, - /// Directory containing `projects.toml`, used for persisting agent data. + /// 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`. @@ -138,7 +144,7 @@ impl GatewayState { /// 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) -> Result { + 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()); } @@ -151,6 +157,8 @@ impl GatewayState { 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)), }) } @@ -898,6 +906,9 @@ const GATEWAY_UI_HTML: &str = r#" .status { margin-top: 1rem; font-size: 0.8rem; color: #64748b; min-height: 1.25rem; } .status.ok { color: #4ade80; } .status.err { color: #f87171; } + .nav { margin-top: 1.25rem; padding-top: 1rem; border-top: 1px solid #334155; display: flex; gap: 1rem; } + .nav a { font-size: 0.8rem; color: #64748b; text-decoration: none; } + .nav a:hover { color: #94a3b8; } @@ -918,6 +929,9 @@ const GATEWAY_UI_HTML: &str = r#"
+ + + +"#; + +/// 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 ─────────────────────────────────────────── /// Start the gateway HTTP server. This is the entry point when `--gateway` is used. @@ -1062,7 +1413,8 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> { .to_path_buf(); let config = GatewayConfig::load(config_path).map_err(std::io::Error::other)?; - let state = GatewayState::new(config, config_dir.clone()).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); let active = state_arc.active_project.read().await.clone(); @@ -1086,17 +1438,23 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> { // Spawn the Matrix bot if `.huskies/bot.toml` exists in the config directory. let gateway_projects: Vec = state_arc.config.projects.keys().cloned().collect(); - spawn_gateway_bot( + let bot_abort = spawn_gateway_bot( &config_dir, Arc::clone(&state_arc.active_project), gateway_projects, port, ); + *state_arc.bot_handle.lock().await = bot_abort; let route = poem::Route::new() .at("/", poem::get(gateway_index_handler)) + .at("/bot-config", poem::get(gateway_bot_config_page_handler)) .at("/api/gateway", poem::get(gateway_api_handler)) .at("/api/gateway/switch", poem::post(gateway_switch_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), @@ -1167,12 +1525,14 @@ fn write_gateway_mcp_json(config_dir: &Path, port: u16) -> Result<(), std::io::E /// 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, port: u16, -) { +) -> Option { use crate::agents::AgentPool; use tokio::sync::{broadcast, mpsc}; @@ -1202,7 +1562,7 @@ fn spawn_gateway_bot( shutdown_rx, Some(active_project), gateway_projects, - ); + ) } // ── Tests ──────────────────────────────────────────────────────────── @@ -1238,7 +1598,7 @@ url = "http://localhost:3002" let config = GatewayConfig { projects: BTreeMap::new(), }; - assert!(GatewayState::new(config, PathBuf::new()).is_err()); + assert!(GatewayState::new(config, PathBuf::from("."), 3000).is_err()); } #[test] @@ -1257,7 +1617,7 @@ url = "http://localhost:3002" }, ); let config = GatewayConfig { projects }; - let state = GatewayState::new(config, PathBuf::new()).unwrap(); + let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap(); let active = state.active_project.blocking_read().clone(); assert_eq!(active, "alpha"); // BTreeMap sorts alphabetically. } @@ -1290,7 +1650,7 @@ url = "http://localhost:3002" }, ); let config = GatewayConfig { projects }; - let state = GatewayState::new(config, PathBuf::new()).unwrap(); + let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap(); let params = json!({ "arguments": { "project": "beta" } }); let resp = handle_switch_project(¶ms, &state).await; @@ -1310,7 +1670,7 @@ url = "http://localhost:3002" }, ); let config = GatewayConfig { projects }; - let state = GatewayState::new(config, PathBuf::new()).unwrap(); + let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap(); let params = json!({ "arguments": { "project": "nonexistent" } }); let resp = handle_switch_project(¶ms, &state).await; @@ -1327,7 +1687,7 @@ url = "http://localhost:3002" }, ); let config = GatewayConfig { projects }; - let state = GatewayState::new(config, PathBuf::new()).unwrap(); + let state = GatewayState::new(config, PathBuf::from("."), 3000).unwrap(); let url = state.active_url().await.unwrap(); assert_eq!(url, "http://my:3001"); @@ -1463,7 +1823,7 @@ enabled = false }, ); let config = GatewayConfig { projects }; - Arc::new(GatewayState::new(config, PathBuf::new()).unwrap()) + Arc::new(GatewayState::new(config, PathBuf::new(), 3000).unwrap()) } #[tokio::test] diff --git a/server/src/main.rs b/server/src/main.rs index 38073509..59c20534 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -860,7 +860,7 @@ async fn main() -> Result<(), std::io::Error> { // Optional Matrix bot: connect to the homeserver and start listening for // messages if `.huskies/bot.toml` is present and enabled. if let Some(ref root) = startup_root { - chat::transport::matrix::spawn_bot( + let _ = chat::transport::matrix::spawn_bot( root, watcher_tx_for_bot, perm_rx_for_bot,