From 846b3e1b4c8c034bec9509ea2e56c87658584d57 Mon Sep 17 00:00:00 2001 From: dave Date: Wed, 20 May 2026 00:18:48 +0000 Subject: [PATCH] =?UTF-8?q?huskies:=20merge=201153=20story=20`huskies=20pr?= =?UTF-8?q?ojects`=20chat=20command=20=E2=80=94=20list=20every=20registere?= =?UTF-8?q?d=20project=20with=20port=20and=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/chat/commands/mod.rs | 15 ++ .../matrix/bot/messages/on_room_message.rs | 36 +++ server/src/chat/transport/matrix/mod.rs | 2 + server/src/chat/transport/matrix/projects.rs | 230 ++++++++++++++++++ server/src/http/gateway/mcp.rs | 54 ++++ 5 files changed, 337 insertions(+) create mode 100644 server/src/chat/transport/matrix/projects.rs diff --git a/server/src/chat/commands/mod.rs b/server/src/chat/commands/mod.rs index 5f4698c1..5025e39f 100644 --- a/server/src/chat/commands/mod.rs +++ b/server/src/chat/commands/mod.rs @@ -284,6 +284,11 @@ pub fn commands() -> &'static [BotCommand] { description: "Rebuild a project's Docker image and swap the container (gateway only): `project-rebuild [--timeout ] [--force]`", handler: handle_project_rebuild_fallback, }, + BotCommand { + name: "projects", + description: "List all registered gateway projects: name, url, ssh port, host path, and adopted/built-in marker (gateway only)", + handler: handle_projects_fallback, + }, ] } @@ -461,6 +466,16 @@ fn handle_health_fallback(_ctx: &CommandContext) -> Option { None } +/// Fallback handler for the `projects` command when it is not intercepted by the +/// async gateway handler in `on_room_message`. In practice this is never called — +/// `projects` is detected and handled before `try_handle_command` runs in gateway +/// mode. The entry exists in the registry so `help` lists it. +/// +/// Returns `None` to prevent the LLM from receiving the raw command text. +fn handle_projects_fallback(_ctx: &CommandContext) -> Option { + None +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/server/src/chat/transport/matrix/bot/messages/on_room_message.rs b/server/src/chat/transport/matrix/bot/messages/on_room_message.rs index b5d1f9a7..ff5628da 100644 --- a/server/src/chat/transport/matrix/bot/messages/on_room_message.rs +++ b/server/src/chat/transport/matrix/bot/messages/on_room_message.rs @@ -33,6 +33,19 @@ fn extract_health_command(message: &str, bot_name: &str, bot_user_id: &str) -> b cmd.eq_ignore_ascii_case("health") } +/// Return `true` when the message is a `projects` command addressed to the bot. +/// +/// Recognised case-insensitively as the single word `projects` after stripping the bot +/// mention prefix. Any trailing whitespace is ignored. +fn extract_projects_command(message: &str, bot_name: &str, bot_user_id: &str) -> bool { + let stripped = crate::chat::util::strip_bot_mention(message, bot_name, bot_user_id); + let trimmed = stripped + .trim() + .trim_start_matches(|c: char| !c.is_alphanumeric()); + let cmd = trimmed.split_whitespace().next().unwrap_or(""); + cmd.eq_ignore_ascii_case("projects") +} + /// Return `true` when the message is a "rebuild gateway" command addressed to the bot. /// /// The command is recognised case-insensitively as `rebuild gateway` after stripping @@ -270,6 +283,7 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message( "project-rebuild", "upgrade", "health", + "projects", ]; let stripped = crate::chat::util::strip_bot_mention( @@ -587,6 +601,28 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message( return; } + // `projects` — gateway-only project enumeration (no liveness checks). + if ctx.is_gateway() + && extract_projects_command( + &user_message, + &ctx.services.bot_name, + ctx.matrix_user_id.as_str(), + ) + { + slog!("[matrix-bot] Handling 'projects' from {sender}"); + let response = super::super::super::projects::run_projects_list(&ctx).await; + 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 bot-level commands (help, status, ambient, …) before invoking // the LLM. All commands are registered in commands.rs — no special-casing // needed here. diff --git a/server/src/chat/transport/matrix/mod.rs b/server/src/chat/transport/matrix/mod.rs index 155de692..dc195b04 100644 --- a/server/src/chat/transport/matrix/mod.rs +++ b/server/src/chat/transport/matrix/mod.rs @@ -33,6 +33,8 @@ pub mod htop; pub mod new_project; /// `project-rebuild ` chat command — rebuild Docker image, swap container, preserve state. pub mod project_rebuild; +/// `projects` chat command — list all registered gateway projects. +pub mod projects; /// Rebuild command — triggers a server rebuild/restart via a bot command. pub mod rebuild; /// Reset command — handles `!reset` bot commands to restart the server state. diff --git a/server/src/chat/transport/matrix/projects.rs b/server/src/chat/transport/matrix/projects.rs new file mode 100644 index 00000000..e491119d --- /dev/null +++ b/server/src/chat/transport/matrix/projects.rs @@ -0,0 +1,230 @@ +//! `projects` chat command — list all registered gateway projects. +//! +//! Reads the live `gateway_projects_store` and returns one row per project: +//! name, URL, ssh_port (if set), host_path (if set), and an `[adopted]` or +//! `[built-in]` marker. Output is alphabetised by project name (BTreeMap +//! order) and the active project is marked with a leading `*`. +//! No liveness checks are performed — this is a pure enumeration. + +use crate::chat::transport::matrix::bot::context::BotContext; + +/// Run the `projects` command and return a formatted Markdown string. +/// +/// Returns a short message when not in gateway mode or when no projects are +/// registered. The active project is prefixed with `*`; all others with +/// a space. Rows are alphabetised because the underlying store is a +/// [`BTreeMap`](std::collections::BTreeMap). +pub async fn run_projects_list(ctx: &BotContext) -> String { + let store = match ctx.gateway_projects_store.as_ref() { + Some(s) => s, + None => return "Not running in gateway mode.".to_string(), + }; + let active = match ctx.gateway_active_project.as_ref() { + Some(ap) => ap.read().await.clone(), + None => String::new(), + }; + let projects = store.read().await; + if projects.is_empty() { + return "No projects registered.".to_string(); + } + let mut lines = Vec::with_capacity(projects.len() + 1); + lines.push(format!("Projects ({} registered):", projects.len())); + for (name, entry) in projects.iter() { + let marker = if *name == active { "*" } else { " " }; + let mut parts = vec![name.clone()]; + if let Some(ref url) = entry.url { + parts.push(url.clone()); + } + if let Some(port) = entry.ssh_port { + parts.push(format!("ssh:{port}")); + } + if let Some(ref path) = entry.host_path { + parts.push(path.clone()); + } + let adopted = if entry.host_path.is_some() { + "[adopted]" + } else { + "[built-in]" + }; + parts.push(adopted.to_string()); + lines.push(format!("{marker} {}", parts.join(" "))); + } + lines.join("\n") +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::service::gateway::config::ProjectEntry; + use std::collections::{BTreeMap, HashMap, HashSet}; + use std::sync::Arc; + use std::sync::atomic::AtomicI64; + use tokio::sync::Mutex as TokioMutex; + use tokio::sync::RwLock; + + async fn run_with_projects(projects: BTreeMap, active: &str) -> String { + let services = crate::services::Services::new_test( + std::path::PathBuf::from("/tmp"), + "bot".to_string(), + ); + let store = Arc::new(RwLock::new(projects)); + let active_project = Arc::new(RwLock::new(active.to_string())); + let ctx = BotContext { + services, + matrix_user_id: "@bot:example.com".parse().unwrap(), + target_room_ids: vec![], + allowed_users: vec![], + history: Arc::new(TokioMutex::new(HashMap::new())), + history_size: 20, + bot_sent_event_ids: Arc::new(TokioMutex::new(HashSet::new())), + htop_sessions: Arc::new(TokioMutex::new(HashMap::new())), + transport: Arc::new(crate::chat::transport::whatsapp::WhatsAppTransport::new( + "test-phone".to_string(), + "test-token".to_string(), + "pipeline_notification".to_string(), + )), + timer_store: Arc::new(crate::service::timer::TimerStore::load( + std::path::PathBuf::from("/tmp/timers-projects.json"), + )), + gateway_active_project: Some(active_project), + gateway_projects_store: Some(store), + handled_incoming_event_ids: Arc::new(TokioMutex::new( + crate::chat::transport::matrix::bot::context::SeenEventIds::new( + crate::chat::transport::matrix::bot::context::SEEN_EVENT_IDS_CAP, + ), + )), + gateway_port: None, + last_matrix_event_ms: Arc::new(AtomicI64::new(chrono::Utc::now().timestamp_millis())), + }; + run_projects_list(&ctx).await + } + + #[tokio::test] + async fn four_projects_all_names_appear_exactly_once() { + let mut projects = BTreeMap::new(); + projects.insert("alpha".into(), ProjectEntry::with_url("http://sled-a:3001")); + projects.insert("beta".into(), ProjectEntry::with_url("http://sled-b:3002")); + projects.insert( + "gamma".into(), + ProjectEntry { + url: Some("http://sled-c:3003".into()), + auth_token: None, + ssh_port: Some(2203), + host_path: Some("/home/user/workspace".into()), + }, + ); + projects.insert( + "delta".into(), + ProjectEntry { + url: None, + auth_token: Some("tok".into()), + ssh_port: None, + host_path: None, + }, + ); + + let response = run_with_projects(projects, "alpha").await; + for name in &["alpha", "beta", "gamma", "delta"] { + let count = response.matches(name).count(); + assert_eq!( + count, 1, + "project '{name}' should appear exactly once, response:\n{response}" + ); + } + } + + #[tokio::test] + async fn active_project_marked_with_star() { + let mut projects = BTreeMap::new(); + projects.insert("proj-a".into(), ProjectEntry::with_url("http://a:3001")); + projects.insert("proj-b".into(), ProjectEntry::with_url("http://b:3002")); + + let response = run_with_projects(projects, "proj-b").await; + let active_line = response.lines().find(|l| l.contains("proj-b")).unwrap(); + assert!( + active_line.starts_with('*'), + "active project line should start with '*': {active_line}" + ); + let inactive_line = response.lines().find(|l| l.contains("proj-a")).unwrap(); + assert!( + !inactive_line.starts_with('*'), + "inactive project line should not start with '*': {inactive_line}" + ); + } + + #[tokio::test] + async fn adopted_project_shows_marker() { + let mut projects = BTreeMap::new(); + projects.insert( + "adopted".into(), + ProjectEntry { + url: Some("http://a:3001".into()), + auth_token: None, + ssh_port: None, + host_path: Some("/home/user/adopted".into()), + }, + ); + projects.insert("builtin".into(), ProjectEntry::with_url("http://b:3002")); + + let response = run_with_projects(projects, "adopted").await; + assert!( + response.contains("[adopted]"), + "adopted project should be marked: {response}" + ); + assert!( + response.contains("[built-in]"), + "built-in project should be marked: {response}" + ); + } + + #[tokio::test] + async fn non_gateway_returns_not_in_gateway_mode() { + let services = crate::services::Services::new_test( + std::path::PathBuf::from("/tmp"), + "bot".to_string(), + ); + let ctx = BotContext { + services, + matrix_user_id: "@bot:example.com".parse().unwrap(), + target_room_ids: vec![], + allowed_users: vec![], + history: Arc::new(TokioMutex::new(HashMap::new())), + history_size: 20, + bot_sent_event_ids: Arc::new(TokioMutex::new(HashSet::new())), + htop_sessions: Arc::new(TokioMutex::new(HashMap::new())), + transport: Arc::new(crate::chat::transport::whatsapp::WhatsAppTransport::new( + "test-phone".to_string(), + "test-token".to_string(), + "pipeline_notification".to_string(), + )), + timer_store: Arc::new(crate::service::timer::TimerStore::load( + std::path::PathBuf::from("/tmp/timers-projects2.json"), + )), + gateway_active_project: None, + gateway_projects_store: None, + handled_incoming_event_ids: Arc::new(TokioMutex::new( + crate::chat::transport::matrix::bot::context::SeenEventIds::new( + crate::chat::transport::matrix::bot::context::SEEN_EVENT_IDS_CAP, + ), + )), + gateway_port: None, + last_matrix_event_ms: Arc::new(AtomicI64::new(chrono::Utc::now().timestamp_millis())), + }; + let response = run_projects_list(&ctx).await; + assert!( + response.contains("Not running in gateway mode"), + "should indicate not in gateway mode: {response}" + ); + } + + #[test] + fn projects_command_registered_in_commands() { + let cmds = crate::chat::commands::commands(); + assert!( + cmds.iter().any(|c| c.name == "projects"), + "projects must be registered in commands()" + ); + } +} diff --git a/server/src/http/gateway/mcp.rs b/server/src/http/gateway/mcp.rs index 4bcdbb54..ba85130c 100644 --- a/server/src/http/gateway/mcp.rs +++ b/server/src/http/gateway/mcp.rs @@ -19,6 +19,7 @@ const GATEWAY_TOOLS: &[&str] = &[ "switch_project", "gateway_status", "gateway_health", + "list_projects", "init_project", "adopt_project", "aggregate_pipeline_status", @@ -65,6 +66,14 @@ pub(crate) fn gateway_tool_definitions() -> Vec { "properties": {} } }), + json!({ + "name": "list_projects", + "description": "List every registered gateway project with its name, url, ssh_port (if set), host_path (if set), and an adopted/built-in marker. The active project is prefixed with *. No liveness checks are performed.", + "inputSchema": { + "type": "object", + "properties": {} + } + }), json!({ "name": "init_project", "description": "Initialize a new huskies project at the given path by scaffolding .huskies/ and related files — the same as running `huskies init `. Prefer this tool over asking the user to run the CLI. If `name` and `url` are supplied the project is also registered in projects.toml so switch_project can reach it immediately.", @@ -423,6 +432,7 @@ async fn handle_gateway_tool( "switch_project" => handle_switch_project_tool(params, state, id).await, "gateway_status" => handle_gateway_status_tool(state, id).await, "gateway_health" => handle_gateway_health_tool(state, id).await, + "list_projects" => handle_list_projects_tool(state, id).await, "init_project" => handle_init_project_tool(params, state, id).await, "adopt_project" => handle_adopt_project_tool(params, state, id).await, "aggregate_pipeline_status" => handle_aggregate_pipeline_status_tool(state, id).await, @@ -539,6 +549,50 @@ async fn handle_gateway_health_tool(state: &GatewayState, id: Option) -> ) } +/// Handle the `list_projects` gateway tool. +/// +/// Returns one row per registered project: name, url, ssh_port (if set), +/// host_path (if set), and an `[adopted]`/`[built-in]` marker. Output is +/// alphabetised by project name (BTreeMap order). The active project is +/// prefixed with `*`. No liveness checks are performed. +async fn handle_list_projects_tool(state: &GatewayState, id: Option) -> JsonRpcResponse { + let active = state.active_project.read().await.clone(); + let projects = state.projects.read().await; + + if projects.is_empty() { + return JsonRpcResponse::success( + id, + json!({ "content": [{ "type": "text", "text": "No projects registered." }] }), + ); + } + + let mut lines = Vec::with_capacity(projects.len() + 1); + lines.push(format!("Projects ({} registered):", projects.len())); + for (name, entry) in projects.iter() { + let marker = if *name == active { "*" } else { " " }; + let mut parts = vec![name.clone()]; + if let Some(ref url) = entry.url { + parts.push(url.clone()); + } + if let Some(port) = entry.ssh_port { + parts.push(format!("ssh:{port}")); + } + if let Some(ref path) = entry.host_path { + parts.push(path.clone()); + } + let adopted = if entry.host_path.is_some() { + "[adopted]" + } else { + "[built-in]" + }; + parts.push(adopted.to_string()); + lines.push(format!("{marker} {}", parts.join(" "))); + } + let text = lines.join("\n"); + + JsonRpcResponse::success(id, json!({ "content": [{ "type": "text", "text": text }] })) +} + async fn handle_init_project_tool( params: &Value, state: &GatewayState,