huskies: merge 558_story_matrix_bot_can_run_on_the_gateway_to_manage_multiple_projects_from_one_chat
This commit is contained in:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user