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
+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"
);
}
}