huskies: merge 1153 story huskies projects chat command — list every registered project with port and status
This commit is contained in:
@@ -284,6 +284,11 @@ pub fn commands() -> &'static [BotCommand] {
|
|||||||
description: "Rebuild a project's Docker image and swap the container (gateway only): `project-rebuild <name> [--timeout <secs>] [--force]`",
|
description: "Rebuild a project's Docker image and swap the container (gateway only): `project-rebuild <name> [--timeout <secs>] [--force]`",
|
||||||
handler: handle_project_rebuild_fallback,
|
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<String> {
|
|||||||
None
|
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<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -33,6 +33,19 @@ fn extract_health_command(message: &str, bot_name: &str, bot_user_id: &str) -> b
|
|||||||
cmd.eq_ignore_ascii_case("health")
|
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.
|
/// 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
|
/// 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",
|
"project-rebuild",
|
||||||
"upgrade",
|
"upgrade",
|
||||||
"health",
|
"health",
|
||||||
|
"projects",
|
||||||
];
|
];
|
||||||
|
|
||||||
let stripped = crate::chat::util::strip_bot_mention(
|
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;
|
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
|
// Check for bot-level commands (help, status, ambient, …) before invoking
|
||||||
// the LLM. All commands are registered in commands.rs — no special-casing
|
// the LLM. All commands are registered in commands.rs — no special-casing
|
||||||
// needed here.
|
// needed here.
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ pub mod htop;
|
|||||||
pub mod new_project;
|
pub mod new_project;
|
||||||
/// `project-rebuild <name>` chat command — rebuild Docker image, swap container, preserve state.
|
/// `project-rebuild <name>` chat command — rebuild Docker image, swap container, preserve state.
|
||||||
pub mod project_rebuild;
|
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.
|
/// Rebuild command — triggers a server rebuild/restart via a bot command.
|
||||||
pub mod rebuild;
|
pub mod rebuild;
|
||||||
/// Reset command — handles `!reset` bot commands to restart the server state.
|
/// Reset command — handles `!reset` bot commands to restart the server state.
|
||||||
|
|||||||
@@ -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<String, ProjectEntry>, 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()"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ const GATEWAY_TOOLS: &[&str] = &[
|
|||||||
"switch_project",
|
"switch_project",
|
||||||
"gateway_status",
|
"gateway_status",
|
||||||
"gateway_health",
|
"gateway_health",
|
||||||
|
"list_projects",
|
||||||
"init_project",
|
"init_project",
|
||||||
"adopt_project",
|
"adopt_project",
|
||||||
"aggregate_pipeline_status",
|
"aggregate_pipeline_status",
|
||||||
@@ -65,6 +66,14 @@ pub(crate) fn gateway_tool_definitions() -> Vec<Value> {
|
|||||||
"properties": {}
|
"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!({
|
json!({
|
||||||
"name": "init_project",
|
"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 <path>`. 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.",
|
"description": "Initialize a new huskies project at the given path by scaffolding .huskies/ and related files — the same as running `huskies init <path>`. 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,
|
"switch_project" => handle_switch_project_tool(params, state, id).await,
|
||||||
"gateway_status" => handle_gateway_status_tool(state, id).await,
|
"gateway_status" => handle_gateway_status_tool(state, id).await,
|
||||||
"gateway_health" => handle_gateway_health_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,
|
"init_project" => handle_init_project_tool(params, state, id).await,
|
||||||
"adopt_project" => handle_adopt_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,
|
"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<Value>) ->
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<Value>) -> 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(
|
async fn handle_init_project_tool(
|
||||||
params: &Value,
|
params: &Value,
|
||||||
state: &GatewayState,
|
state: &GatewayState,
|
||||||
|
|||||||
Reference in New Issue
Block a user