diff --git a/server/src/chat/commands/mod.rs b/server/src/chat/commands/mod.rs index c1f49f12..5b6bd249 100644 --- a/server/src/chat/commands/mod.rs +++ b/server/src/chat/commands/mod.rs @@ -19,6 +19,7 @@ mod help; pub(crate) mod loc; mod logs; mod move_story; +mod new_project; mod overview; mod run_tests; mod setup; @@ -262,6 +263,11 @@ pub fn commands() -> &'static [BotCommand] { description: "List orphaned worktrees (dry run), or `cleanup_worktrees --confirm` to remove them", handler: handle_cleanup_worktrees_fallback, }, + BotCommand { + name: "new", + description: "Bootstrap a new project container (gateway only): `new project `", + handler: new_project::handle_new_project_fallback, + }, ] } diff --git a/server/src/chat/commands/new_project.rs b/server/src/chat/commands/new_project.rs new file mode 100644 index 00000000..c0455b6d --- /dev/null +++ b/server/src/chat/commands/new_project.rs @@ -0,0 +1,19 @@ +//! `new project` command stub. +//! +//! The command is handled asynchronously in the Matrix transport's +//! `on_room_message` handler (gateway mode only). This file exists so that +//! `help` lists the command and the gateway proxy block does not forward it +//! to the active project sled. + +use super::CommandContext; + +/// Fallback handler for the `new` command when it is not intercepted by the +/// async gateway handler in `on_room_message`. In practice this is never +/// called — `new project` is detected and handled before `try_handle_command` +/// runs in gateway mode, and in standalone mode there is no matching project +/// bootstrap context. +/// +/// Returns `None` to prevent the LLM from receiving the raw command text. +pub fn handle_new_project_fallback(_ctx: &CommandContext) -> Option { + None +} diff --git a/server/src/chat/transport/matrix/bot/context.rs b/server/src/chat/transport/matrix/bot/context.rs index b6ce60c8..8da73421 100644 --- a/server/src/chat/transport/matrix/bot/context.rs +++ b/server/src/chat/transport/matrix/bot/context.rs @@ -1,5 +1,6 @@ //! Matrix bot context — shared state for the Matrix bot (rooms, history, permissions). use crate::chat::ChatTransport; +use crate::service::gateway::config::ProjectEntry; use crate::service::timer::TimerStore; use crate::services::Services; use matrix_sdk::ruma::{OwnedEventId, OwnedRoomId, OwnedUserId}; @@ -94,6 +95,11 @@ pub struct BotContext { /// Used to proxy bot commands to the active project over WebSocket (`/ws`). /// Empty in standalone mode. pub gateway_project_urls: BTreeMap, + /// In gateway mode: shared live projects map from [`GatewayState`]. + /// + /// The `new project` command writes here so HTTP handlers see the new entry + /// immediately without requiring a gateway restart. `None` in standalone mode. + pub gateway_projects_store: Option>>>, /// Pipeline transition events buffered since the last LLM turn. /// /// A background task appends one compact audit line per real stage @@ -300,6 +306,7 @@ mod tests { gateway_active_project, gateway_projects, gateway_project_urls, + gateway_projects_store: None, pending_pipeline_events: Arc::new(TokioMutex::new(Vec::new())), pending_gateway_events: Arc::new(TokioMutex::new(Vec::new())), handled_incoming_event_ids: Arc::new(TokioMutex::new(SeenEventIds::new( 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 b8851bb9..b3451de9 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 @@ -193,7 +193,7 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message( if ctx.is_gateway() { // Commands that are meaningful on the gateway itself (no project state needed). const GATEWAY_LOCAL_COMMANDS: &[&str] = - &["help", "ambient", "reset", "switch", "all_status"]; + &["help", "ambient", "reset", "switch", "all_status", "new"]; let stripped = crate::chat::util::strip_bot_mention( &user_message, @@ -260,6 +260,38 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message( // Gateway-local commands and freeform text fall through to normal handling below. } + // In gateway mode, handle the "new project " command to bootstrap a + // bare project container and register it with the gateway. + if ctx.is_gateway() + && let Some(project_name) = super::super::super::new_project::extract_new_project_command( + &user_message, + &ctx.services.bot_name, + ctx.matrix_user_id.as_str(), + ) + { + slog!("[matrix-bot] Handling new project command from {sender}: name={project_name:?}"); + let response = if let Some(ref store) = ctx.gateway_projects_store { + super::super::super::new_project::handle_new_project( + &project_name, + store, + &ctx.services.project_root, + ) + .await + } else { + "Gateway projects store unavailable — cannot create project.".to_string() + }; + 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/bot/run.rs b/server/src/chat/transport/matrix/bot/run.rs index 0616840a..a9da7217 100644 --- a/server/src/chat/transport/matrix/bot/run.rs +++ b/server/src/chat/transport/matrix/bot/run.rs @@ -30,6 +30,13 @@ pub async fn run_bot( gateway_active_project: Option>>, gateway_projects: Vec, gateway_project_urls: std::collections::BTreeMap, + gateway_projects_store: Option< + Arc< + RwLock< + std::collections::BTreeMap, + >, + >, + >, timer_store: Arc, gateway_event_rx: Option< tokio::sync::broadcast::Receiver, @@ -399,6 +406,7 @@ pub async fn run_bot( gateway_active_project, gateway_projects, gateway_project_urls, + gateway_projects_store, pending_pipeline_events, pending_gateway_events, handled_incoming_event_ids: Arc::new(TokioMutex::new(super::context::SeenEventIds::new( diff --git a/server/src/chat/transport/matrix/mod.rs b/server/src/chat/transport/matrix/mod.rs index 919b134f..5c8e4f44 100644 --- a/server/src/chat/transport/matrix/mod.rs +++ b/server/src/chat/transport/matrix/mod.rs @@ -27,6 +27,8 @@ pub(crate) mod config; pub mod delete; /// htop-style agent monitor command — renders a live process table in Matrix. pub mod htop; +/// `new project ` chat command — Phase 1 gateway project bootstrap. +pub mod new_project; /// 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. @@ -81,6 +83,13 @@ pub fn spawn_bot( gateway_active_project: Option>>, gateway_projects: Vec, gateway_project_urls: std::collections::BTreeMap, + gateway_projects_store: Option< + Arc< + RwLock< + std::collections::BTreeMap, + >, + >, + >, timer_store: Arc, gateway_event_rx: Option< tokio::sync::broadcast::Receiver, @@ -122,6 +131,7 @@ pub fn spawn_bot( gateway_active_project, gateway_projects, gateway_project_urls, + gateway_projects_store, timer_store, gateway_event_rx, ) diff --git a/server/src/chat/transport/matrix/new_project.rs b/server/src/chat/transport/matrix/new_project.rs new file mode 100644 index 00000000..d496495a --- /dev/null +++ b/server/src/chat/transport/matrix/new_project.rs @@ -0,0 +1,293 @@ +//! `new project ` chat command — Phase 1 bootstrap. +//! +//! Provisions a bare project container and registers it with the gateway. +//! The command is gateway-only: `new project [--stack ]`. +//! Phase 1 ignores `--stack`; stack-aware images arrive in Phase 2. + +use std::collections::BTreeMap; +use std::path::Path; +use std::sync::Arc; +use tokio::sync::RwLock; + +use crate::service::gateway::config::ProjectEntry; + +/// Parse a `new project ` command from a chat message. +/// +/// Returns `Some(name)` when the stripped message starts with "new project" +/// (case-insensitive). An empty name (bare "new project" with no arg) is +/// returned as `Some("")` so the handler can emit a usage error. Returns `None` +/// for any other message. +pub fn extract_new_project_command( + message: &str, + bot_name: &str, + bot_user_id: &str, +) -> Option { + let mention_stripped = crate::chat::util::strip_bot_mention(message, bot_name, bot_user_id); + // Strip leading punctuation (e.g. colon after "@timmy: new project …") + let trimmed = mention_stripped + .trim() + .trim_start_matches(|c: char| !c.is_alphanumeric()); + + let mut words = trimmed.split_whitespace(); + let first = words.next()?; + if !first.eq_ignore_ascii_case("new") { + return None; + } + let second = words.next()?; + if !second.eq_ignore_ascii_case("project") { + return None; + } + // Third word is the project name; later words (flags) are ignored in Phase 1. + let name = words.next().unwrap_or("").to_string(); + Some(name) +} + +/// Bootstrap a new project from the `new project ` command. +/// +/// Creates `~/huskies//`, scaffolds `.huskies/`, runs `git init`, +/// launches a Docker container (`huskies-project-base`), and registers the +/// project in both the gateway's in-memory store and the CRDT. +/// +/// On any failure after the host directory is created, the directory is removed +/// and the error message includes "Partial state removed at ``". +pub async fn handle_new_project( + name: &str, + projects_store: &Arc>>, + config_dir: &Path, +) -> String { + let name = name.trim(); + + if name.is_empty() { + return "Usage: `new project ` — e.g. `new project myapp`".to_string(); + } + if !name + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') + { + return format!( + "Invalid project name `{name}`. \ + Use letters, digits, hyphens, or underscores only." + ); + } + + // Name conflict — check both the in-memory store and the CRDT. + { + let projects = projects_store.read().await; + if projects.contains_key(name) { + return format!( + "Project `{name}` is already registered. \ + Use `switch {name}` to activate it." + ); + } + } + + // Default host path: ~/huskies// + let home = std::env::var("HOME").unwrap_or_else(|_| "/home/huskies".to_string()); + let host_path = std::path::PathBuf::from(home).join("huskies").join(name); + + if host_path.exists() { + return format!( + "Path `{}` already exists. \ + Choose a different project name or remove the directory first.", + host_path.display() + ); + } + + // ── Create host directory ──────────────────────────────────────────────── + + if let Err(e) = crate::service::gateway::io::ensure_directory(&host_path) { + return format!("Failed to create `{}`: {e}", host_path.display()); + } + + // ── Scaffold .huskies/ ─────────────────────────────────────────────────── + + if let Err(e) = crate::service::gateway::io::scaffold_project(&host_path) { + let _ = tokio::fs::remove_dir_all(&host_path).await; + return format!( + "Scaffold failed: {e}\n\nPartial state removed at `{}`.", + host_path.display() + ); + } + crate::service::gateway::io::init_wizard_state(&host_path); + + // ── git init ───────────────────────────────────────────────────────────── + + match tokio::process::Command::new("git") + .arg("init") + .arg(&host_path) + .output() + .await + { + Err(e) => { + let _ = tokio::fs::remove_dir_all(&host_path).await; + return format!( + "git init failed: {e}\n\nPartial state removed at `{}`.", + host_path.display() + ); + } + Ok(out) if !out.status.success() => { + let stderr = String::from_utf8_lossy(&out.stderr); + let _ = tokio::fs::remove_dir_all(&host_path).await; + return format!( + "git init failed: {}\n\nPartial state removed at `{}`.", + stderr.trim(), + host_path.display() + ); + } + Ok(_) => {} + } + + // ── Allocate port and launch container ─────────────────────────────────── + + let port = find_free_port(3100); + let container_url = format!("http://127.0.0.1:{port}"); + let container_name = format!("huskies-{name}"); + + let docker_result = tokio::process::Command::new("docker") + .args([ + "run", + "-d", + "--name", + &container_name, + "-p", + &format!("127.0.0.1:{port}:3001"), + "-v", + &format!("{}:/workspace", host_path.display()), + "--restart", + "unless-stopped", + "huskies-project-base", + "huskies", + "/workspace", + ]) + .output() + .await; + + match docker_result { + Ok(out) if out.status.success() => { + // Register in the CRDT (survives restarts). + crate::crdt_state::write_gateway_project(name, &container_url); + + // Update the in-memory projects store and persist to projects.toml. + { + let mut projects = projects_store.write().await; + projects.insert(name.to_string(), ProjectEntry::with_url(&container_url)); + crate::service::gateway::io::save_config(&projects, config_dir).await; + } + + crate::slog!("[new-project] Created project '{name}' at {container_url}"); + + format!( + "Project **{name}** is ready.\n\ + - Host path: `{host}`\n\ + - Container: `{container_name}` → `{container_url}`\n\ + \n\ + Use `switch {name}` then `status` to view the pipeline.\n\ + *Stack tooling (Rust, Node, etc.) comes in Phase 2 — \ + pass `--stack ` now for forward-compat.*", + host = host_path.display() + ) + } + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr); + let _ = tokio::fs::remove_dir_all(&host_path).await; + format!( + "Docker container launch failed: {}\n\nPartial state removed at `{}`.", + stderr.trim(), + host_path.display() + ) + } + Err(e) => { + let _ = tokio::fs::remove_dir_all(&host_path).await; + format!( + "Docker container launch failed: {e}\n\nPartial state removed at `{}`.", + host_path.display() + ) + } + } +} + +/// Find a free TCP port by attempting to bind starting from `start`. +/// +/// Scans up to 100 ports above `start` and returns the first available one. +/// Falls back to `start` if none are found (unlikely in practice). +fn find_free_port(start: u16) -> u16 { + for port in start..start + 100 { + if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() { + return port; + } + } + start +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_parses_name() { + assert_eq!( + extract_new_project_command("@timmy new project myapp", "Timmy", "@timmy:srv.local"), + Some("myapp".to_string()) + ); + } + + #[test] + fn extract_case_insensitive() { + assert_eq!( + extract_new_project_command("@timmy NEW PROJECT myapp", "Timmy", "@timmy:srv.local"), + Some("myapp".to_string()) + ); + } + + #[test] + fn extract_bare_project_keyword_returns_empty_name() { + assert_eq!( + extract_new_project_command("@timmy new project", "Timmy", "@timmy:srv.local"), + Some("".to_string()) + ); + } + + #[test] + fn extract_ignores_flags_after_name() { + assert_eq!( + extract_new_project_command( + "@timmy new project myapp --stack rust", + "Timmy", + "@timmy:srv.local" + ), + Some("myapp".to_string()) + ); + } + + #[test] + fn extract_no_match_for_other_commands() { + assert_eq!( + extract_new_project_command("@timmy status", "Timmy", "@timmy:srv.local"), + None + ); + } + + #[test] + fn extract_no_match_when_second_word_is_not_project() { + assert_eq!( + extract_new_project_command("@timmy new myapp", "Timmy", "@timmy:srv.local"), + None + ); + } + + #[test] + fn extract_handles_extra_whitespace() { + assert_eq!( + extract_new_project_command( + "@timmy new project myapp", + "Timmy", + "@timmy:srv.local" + ), + Some("myapp".to_string()) + ); + } +} diff --git a/server/src/gateway/mod.rs b/server/src/gateway/mod.rs index d4c60423..f9885bda 100644 --- a/server/src/gateway/mod.rs +++ b/server/src/gateway/mod.rs @@ -126,6 +126,7 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> { Arc::clone(&state_arc.active_project), gateway_projects, gateway_project_urls, + Arc::clone(&state_arc.projects), port, Some(state_arc.event_tx.clone()), Arc::clone(&state_arc.perm_rx), diff --git a/server/src/main.rs b/server/src/main.rs index 0e263648..e138e954 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -366,6 +366,7 @@ async fn main() -> Result<(), std::io::Error> { None, vec![], std::collections::BTreeMap::new(), + None, timer_store_for_bot, None, ); diff --git a/server/src/service/gateway/io.rs b/server/src/service/gateway/io.rs index 476a6494..102a784a 100644 --- a/server/src/service/gateway/io.rs +++ b/server/src/service/gateway/io.rs @@ -500,11 +500,13 @@ pub type ActiveProject = std::sync::Arc>; /// Returns `(abort_handle, shutdown_tx)`. The caller **must** hold /// `shutdown_tx` for the bot's lifetime and send `Some(ShutdownReason)` on it /// before process exit so the bot can announce "going offline" to its rooms. +#[allow(clippy::too_many_arguments)] pub fn spawn_gateway_bot( config_dir: &Path, active_project: ActiveProject, gateway_projects: Vec, gateway_project_urls: BTreeMap, + gateway_projects_store: std::sync::Arc>>, port: u16, gateway_event_tx: Option>, perm_rx: std::sync::Arc< @@ -578,6 +580,7 @@ pub fn spawn_gateway_bot( Some(active_project), gateway_projects, gateway_project_urls, + Some(gateway_projects_store), timer_store, gateway_event_rx, ); @@ -602,11 +605,14 @@ mod tests { let (_perm_tx, perm_rx) = tokio::sync::mpsc::unbounded_channel::(); let perm_rx = std::sync::Arc::new(tokio::sync::Mutex::new(perm_rx)); + let projects_store = + std::sync::Arc::new(tokio::sync::RwLock::new(std::collections::BTreeMap::new())); let (handle, shutdown_tx) = spawn_gateway_bot( tmp.path(), active, vec!["proj".to_string()], std::collections::BTreeMap::new(), + projects_store, 3001, Some(event_tx), perm_rx, diff --git a/server/src/service/gateway/mod.rs b/server/src/service/gateway/mod.rs index feebcf6c..5531f2ac 100644 --- a/server/src/service/gateway/mod.rs +++ b/server/src/service/gateway/mod.rs @@ -661,6 +661,7 @@ pub async fn save_bot_config_and_restart(state: &GatewayState, content: &str) -> Arc::clone(&state.active_project), gateway_projects, gateway_project_urls, + Arc::clone(&state.projects), state.port, Some(state.event_tx.clone()), Arc::clone(&state.perm_rx),