huskies: merge 558_story_matrix_bot_can_run_on_the_gateway_to_manage_multiple_projects_from_one_chat

This commit is contained in:
dave
2026-04-14 09:57:11 +00:00
parent 28777b0c77
commit d824dc4b73
6 changed files with 239 additions and 4 deletions
@@ -8,7 +8,7 @@ use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex as TokioMutex;
use tokio::sync::{mpsc, oneshot};
use tokio::sync::{RwLock, mpsc, oneshot};
use super::history::ConversationHistory;
@@ -59,6 +59,12 @@ pub struct BotContext {
pub transport: Arc<dyn ChatTransport>,
/// Persistent store for pending deferred-start timers.
pub timer_store: Arc<TimerStore>,
/// In gateway mode: the currently active project (shared with the gateway HTTP handler).
/// `None` in standalone single-project mode.
pub gateway_active_project: Option<Arc<RwLock<String>>>,
/// In gateway mode: valid project names accepted by the `switch` command.
/// Empty in standalone mode.
pub gateway_projects: Vec<String>,
}
// ---------------------------------------------------------------------------
@@ -110,6 +116,8 @@ mod tests {
timer_store: Arc::new(crate::chat::timer::TimerStore::load(
std::path::PathBuf::from("/tmp/timers.json"),
)),
gateway_active_project: None,
gateway_projects: vec![],
};
// Clone must work (required by Matrix SDK event handler injection).
let _cloned = ctx.clone();
@@ -450,6 +450,47 @@ pub(super) async fn on_room_message(
return;
}
// In gateway mode, handle the "switch <project>" command to change the
// active project without invoking the LLM.
if let Some(ref active_project) = ctx.gateway_active_project {
let stripped = crate::chat::util::strip_bot_mention(
&user_message,
&ctx.bot_name,
ctx.bot_user_id.as_str(),
)
.trim()
.trim_start_matches(|c: char| !c.is_alphanumeric())
.to_string();
let (cmd, arg) = match stripped.split_once(char::is_whitespace) {
Some((c, a)) => (c.to_string(), a.trim().to_string()),
None => (stripped.clone(), String::new()),
};
if cmd.eq_ignore_ascii_case("switch") {
let response = if arg.is_empty() {
let available = ctx.gateway_projects.join(", ");
format!("Usage: `switch <project>`. Available projects: {available}")
} else if ctx.gateway_projects.iter().any(|p| p == &arg) {
*active_project.write().await = arg.clone();
format!("Switched to project **{arg}**.")
} else {
let available = ctx.gateway_projects.join(", ");
format!("Unknown project `{arg}`. Available: {available}")
};
let html = markdown_to_html(&response);
if let Ok(msg_id) = ctx
.transport
.send_message(&room_id_str, &response, &html)
.await
&& let Ok(event_id) = msg_id.parse()
{
ctx.bot_sent_event_ids.lock().await.insert(event_id);
}
return;
}
}
// Check for the timer command, which requires async file I/O and cannot
// be handled by the sync command registry.
if let Some(timer_cmd) = crate::chat::timer::extract_timer_command(
@@ -501,8 +542,14 @@ pub(super) async fn handle_message(
// The prompt is just the current message with sender attribution.
// Prior conversation context is carried by the Claude Code session.
let bot_name = &ctx.bot_name;
let active_project_ctx = if let Some(ref ap) = ctx.gateway_active_project {
let name = ap.read().await.clone();
format!("[Active project: {name}]\n")
} else {
String::new()
};
let prompt = format!(
"[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n\n{}",
"[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n{active_project_ctx}\n{}",
format_user_prompt(&sender, &user_message)
);
+6 -1
View File
@@ -8,7 +8,7 @@ use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use tokio::sync::Mutex as TokioMutex;
use tokio::sync::{mpsc, watch};
use tokio::sync::{RwLock, mpsc, watch};
use super::context::BotContext;
use super::format::{format_startup_announcement, markdown_to_html};
@@ -19,6 +19,7 @@ use super::verification::{on_room_verification_request, on_to_device_verificatio
/// Connect to the Matrix homeserver, join all configured rooms, and start
/// listening for messages. Runs the full Matrix sync loop — call from a
/// `tokio::spawn` task so it doesn't block the main thread.
#[allow(clippy::too_many_arguments)]
pub async fn run_bot(
config: super::super::config::BotConfig,
project_root: PathBuf,
@@ -27,6 +28,8 @@ pub async fn run_bot(
perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<crate::http::context::PermissionForward>>>,
agents: Arc<AgentPool>,
shutdown_rx: watch::Receiver<Option<crate::rebuild::ShutdownReason>>,
gateway_active_project: Option<Arc<RwLock<String>>>,
gateway_projects: Vec<String>,
) -> Result<(), String> {
let store_path = project_root.join(".huskies").join("matrix_store");
let client = Client::builder()
@@ -242,6 +245,8 @@ pub async fn run_bot(
htop_sessions: Arc::new(TokioMutex::new(HashMap::new())),
transport: Arc::clone(&transport),
timer_store,
gateway_active_project,
gateway_projects,
};
slog!(
+5 -1
View File
@@ -37,7 +37,7 @@ use crate::io::watcher::WatcherEvent;
use crate::rebuild::ShutdownReason;
use std::path::Path;
use std::sync::Arc;
use tokio::sync::{Mutex as TokioMutex, broadcast, mpsc, watch};
use tokio::sync::{Mutex as TokioMutex, RwLock, broadcast, mpsc, watch};
/// Attempt to start the Matrix bot.
///
@@ -64,6 +64,8 @@ pub fn spawn_bot(
perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<PermissionForward>>>,
agents: Arc<AgentPool>,
shutdown_rx: watch::Receiver<Option<ShutdownReason>>,
gateway_active_project: Option<Arc<RwLock<String>>>,
gateway_projects: Vec<String>,
) {
let config = match BotConfig::load(project_root) {
Some(c) => c,
@@ -100,6 +102,8 @@ pub fn spawn_bot(
perm_rx,
agents,
shutdown_rx,
gateway_active_project,
gateway_projects,
)
.await
{
+169
View File
@@ -19,6 +19,9 @@ use std::path::Path;
use std::sync::Arc;
use tokio::sync::RwLock;
// Re-export active_project type alias for clarity in gateway bot helpers.
type ActiveProject = Arc<RwLock<String>>;
// ── Config ───────────────────────────────────────────────────────────
/// A single project entry in `projects.toml`.
@@ -563,6 +566,27 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
.join(", ")
);
// Locate the gateway config directory (parent of `projects.toml`).
let config_dir = config_path
.parent()
.unwrap_or(std::path::Path::new("."))
.to_path_buf();
// Write `.mcp.json` so that the gateway's Matrix bot's Claude Code CLI
// connects to this gateway's MCP endpoint (which proxies to the active project).
if let Err(e) = write_gateway_mcp_json(&config_dir, port) {
crate::slog!("[gateway] Warning: could not write .mcp.json: {e}");
}
// Spawn the Matrix bot if `.huskies/bot.toml` exists in the config directory.
let gateway_projects: Vec<String> = state_arc.config.projects.keys().cloned().collect();
spawn_gateway_bot(
&config_dir,
Arc::clone(&state_arc.active_project),
gateway_projects,
port,
);
let route = poem::Route::new()
.at(
"/mcp",
@@ -580,6 +604,72 @@ 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 `<config_dir>/.huskies/bot.toml`. If absent or disabled the function
/// returns immediately without spawning anything. When the bot is enabled it
/// receives a shared reference to the gateway's active-project `RwLock` so the
/// `switch` command can change the active project without going through HTTP.
fn spawn_gateway_bot(
config_dir: &Path,
active_project: ActiveProject,
gateway_projects: Vec<String>,
port: u16,
) {
use crate::agents::AgentPool;
use tokio::sync::{broadcast, mpsc};
// Create a watcher broadcast channel (no file-system watcher in gateway mode).
let (watcher_tx, _) = broadcast::channel(16);
// Create a dummy permission channel — permission prompts are not forwarded
// across the proxy boundary in this initial implementation.
let (_perm_tx, perm_rx) = mpsc::unbounded_channel();
let perm_rx = Arc::new(tokio::sync::Mutex::new(perm_rx));
// Create a shutdown watch channel. Gateway process exit signals Ctrl-C
// via OS signal, not through a watch channel, so we leave this at None
// (no shutdown announcement). The sender is kept alive for the duration.
let (shutdown_tx, shutdown_rx) =
tokio::sync::watch::channel::<Option<crate::rebuild::ShutdownReason>>(None);
// Keep sender alive so the receiver is never prematurely closed.
std::mem::forget(shutdown_tx);
let agents = Arc::new(AgentPool::new(port, watcher_tx.clone()));
crate::chat::transport::matrix::spawn_bot(
config_dir,
watcher_tx,
perm_rx,
agents,
shutdown_rx,
Some(active_project),
gateway_projects,
);
}
// ── Tests ────────────────────────────────────────────────────────────
#[cfg(test)]
@@ -747,4 +837,83 @@ url = "http://localhost:9999"
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"
);
}
}
+2
View File
@@ -824,6 +824,8 @@ async fn main() -> Result<(), std::io::Error> {
perm_rx_for_bot,
Arc::clone(&startup_agents),
matrix_shutdown_rx,
None,
vec![],
);
} else {
// Keep the receiver alive (drop it) so the sender never errors.