huskies: merge 558_story_matrix_bot_can_run_on_the_gateway_to_manage_multiple_projects_from_one_chat
This commit is contained in:
@@ -8,7 +8,7 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex as TokioMutex;
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
use tokio::sync::{mpsc, oneshot};
|
use tokio::sync::{RwLock, mpsc, oneshot};
|
||||||
|
|
||||||
use super::history::ConversationHistory;
|
use super::history::ConversationHistory;
|
||||||
|
|
||||||
@@ -59,6 +59,12 @@ pub struct BotContext {
|
|||||||
pub transport: Arc<dyn ChatTransport>,
|
pub transport: Arc<dyn ChatTransport>,
|
||||||
/// Persistent store for pending deferred-start timers.
|
/// Persistent store for pending deferred-start timers.
|
||||||
pub timer_store: Arc<TimerStore>,
|
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(
|
timer_store: Arc::new(crate::chat::timer::TimerStore::load(
|
||||||
std::path::PathBuf::from("/tmp/timers.json"),
|
std::path::PathBuf::from("/tmp/timers.json"),
|
||||||
)),
|
)),
|
||||||
|
gateway_active_project: None,
|
||||||
|
gateway_projects: vec![],
|
||||||
};
|
};
|
||||||
// Clone must work (required by Matrix SDK event handler injection).
|
// Clone must work (required by Matrix SDK event handler injection).
|
||||||
let _cloned = ctx.clone();
|
let _cloned = ctx.clone();
|
||||||
|
|||||||
@@ -450,6 +450,47 @@ pub(super) async fn on_room_message(
|
|||||||
return;
|
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
|
// Check for the timer command, which requires async file I/O and cannot
|
||||||
// be handled by the sync command registry.
|
// be handled by the sync command registry.
|
||||||
if let Some(timer_cmd) = crate::chat::timer::extract_timer_command(
|
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.
|
// The prompt is just the current message with sender attribution.
|
||||||
// Prior conversation context is carried by the Claude Code session.
|
// Prior conversation context is carried by the Claude Code session.
|
||||||
let bot_name = &ctx.bot_name;
|
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!(
|
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)
|
format_user_prompt(&sender, &user_message)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use std::path::PathBuf;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use tokio::sync::Mutex as TokioMutex;
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
use tokio::sync::{mpsc, watch};
|
use tokio::sync::{RwLock, mpsc, watch};
|
||||||
|
|
||||||
use super::context::BotContext;
|
use super::context::BotContext;
|
||||||
use super::format::{format_startup_announcement, markdown_to_html};
|
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
|
/// Connect to the Matrix homeserver, join all configured rooms, and start
|
||||||
/// listening for messages. Runs the full Matrix sync loop — call from a
|
/// listening for messages. Runs the full Matrix sync loop — call from a
|
||||||
/// `tokio::spawn` task so it doesn't block the main thread.
|
/// `tokio::spawn` task so it doesn't block the main thread.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn run_bot(
|
pub async fn run_bot(
|
||||||
config: super::super::config::BotConfig,
|
config: super::super::config::BotConfig,
|
||||||
project_root: PathBuf,
|
project_root: PathBuf,
|
||||||
@@ -27,6 +28,8 @@ pub async fn run_bot(
|
|||||||
perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<crate::http::context::PermissionForward>>>,
|
perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<crate::http::context::PermissionForward>>>,
|
||||||
agents: Arc<AgentPool>,
|
agents: Arc<AgentPool>,
|
||||||
shutdown_rx: watch::Receiver<Option<crate::rebuild::ShutdownReason>>,
|
shutdown_rx: watch::Receiver<Option<crate::rebuild::ShutdownReason>>,
|
||||||
|
gateway_active_project: Option<Arc<RwLock<String>>>,
|
||||||
|
gateway_projects: Vec<String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let store_path = project_root.join(".huskies").join("matrix_store");
|
let store_path = project_root.join(".huskies").join("matrix_store");
|
||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
@@ -242,6 +245,8 @@ pub async fn run_bot(
|
|||||||
htop_sessions: Arc::new(TokioMutex::new(HashMap::new())),
|
htop_sessions: Arc::new(TokioMutex::new(HashMap::new())),
|
||||||
transport: Arc::clone(&transport),
|
transport: Arc::clone(&transport),
|
||||||
timer_store,
|
timer_store,
|
||||||
|
gateway_active_project,
|
||||||
|
gateway_projects,
|
||||||
};
|
};
|
||||||
|
|
||||||
slog!(
|
slog!(
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ use crate::io::watcher::WatcherEvent;
|
|||||||
use crate::rebuild::ShutdownReason;
|
use crate::rebuild::ShutdownReason;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
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.
|
/// Attempt to start the Matrix bot.
|
||||||
///
|
///
|
||||||
@@ -64,6 +64,8 @@ pub fn spawn_bot(
|
|||||||
perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<PermissionForward>>>,
|
perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<PermissionForward>>>,
|
||||||
agents: Arc<AgentPool>,
|
agents: Arc<AgentPool>,
|
||||||
shutdown_rx: watch::Receiver<Option<ShutdownReason>>,
|
shutdown_rx: watch::Receiver<Option<ShutdownReason>>,
|
||||||
|
gateway_active_project: Option<Arc<RwLock<String>>>,
|
||||||
|
gateway_projects: Vec<String>,
|
||||||
) {
|
) {
|
||||||
let config = match BotConfig::load(project_root) {
|
let config = match BotConfig::load(project_root) {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
@@ -100,6 +102,8 @@ pub fn spawn_bot(
|
|||||||
perm_rx,
|
perm_rx,
|
||||||
agents,
|
agents,
|
||||||
shutdown_rx,
|
shutdown_rx,
|
||||||
|
gateway_active_project,
|
||||||
|
gateway_projects,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ use std::path::Path;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
// Re-export active_project type alias for clarity in gateway bot helpers.
|
||||||
|
type ActiveProject = Arc<RwLock<String>>;
|
||||||
|
|
||||||
// ── Config ───────────────────────────────────────────────────────────
|
// ── Config ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// A single project entry in `projects.toml`.
|
/// 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(", ")
|
.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()
|
let route = poem::Route::new()
|
||||||
.at(
|
.at(
|
||||||
"/mcp",
|
"/mcp",
|
||||||
@@ -580,6 +604,72 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
|
|||||||
.await
|
.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 ────────────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -747,4 +837,83 @@ url = "http://localhost:9999"
|
|||||||
let result = GatewayConfig::load(Path::new("/nonexistent/projects.toml"));
|
let result = GatewayConfig::load(Path::new("/nonexistent/projects.toml"));
|
||||||
assert!(result.is_err());
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -824,6 +824,8 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
perm_rx_for_bot,
|
perm_rx_for_bot,
|
||||||
Arc::clone(&startup_agents),
|
Arc::clone(&startup_agents),
|
||||||
matrix_shutdown_rx,
|
matrix_shutdown_rx,
|
||||||
|
None,
|
||||||
|
vec![],
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Keep the receiver alive (drop it) so the sender never errors.
|
// Keep the receiver alive (drop it) so the sender never errors.
|
||||||
|
|||||||
Reference in New Issue
Block a user