huskies: merge 1106 story Chat bootstrap Phase 1: new project chat command spawns a bare project container and registers it with the gateway

This commit is contained in:
dave
2026-05-16 22:34:24 +00:00
parent 5c63618b30
commit 10d992a7e4
11 changed files with 385 additions and 1 deletions
+6
View File
@@ -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 <name>`",
handler: new_project::handle_new_project_fallback,
},
]
}
+19
View File
@@ -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<String> {
None
}
@@ -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<String, String>,
/// 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<Arc<RwLock<BTreeMap<String, ProjectEntry>>>>,
/// 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(
@@ -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 <name>" 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.
@@ -30,6 +30,13 @@ pub async fn run_bot(
gateway_active_project: Option<Arc<RwLock<String>>>,
gateway_projects: Vec<String>,
gateway_project_urls: std::collections::BTreeMap<String, String>,
gateway_projects_store: Option<
Arc<
RwLock<
std::collections::BTreeMap<String, crate::service::gateway::config::ProjectEntry>,
>,
>,
>,
timer_store: Arc<TimerStore>,
gateway_event_rx: Option<
tokio::sync::broadcast::Receiver<crate::service::gateway::GatewayStatusEvent>,
@@ -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(
+10
View File
@@ -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 <name>` 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<Arc<RwLock<String>>>,
gateway_projects: Vec<String>,
gateway_project_urls: std::collections::BTreeMap<String, String>,
gateway_projects_store: Option<
Arc<
RwLock<
std::collections::BTreeMap<String, crate::service::gateway::config::ProjectEntry>,
>,
>,
>,
timer_store: Arc<TimerStore>,
gateway_event_rx: Option<
tokio::sync::broadcast::Receiver<crate::service::gateway::GatewayStatusEvent>,
@@ -122,6 +131,7 @@ pub fn spawn_bot(
gateway_active_project,
gateway_projects,
gateway_project_urls,
gateway_projects_store,
timer_store,
gateway_event_rx,
)
@@ -0,0 +1,293 @@
//! `new project <name>` chat command — Phase 1 bootstrap.
//!
//! Provisions a bare project container and registers it with the gateway.
//! The command is gateway-only: `new project <name> [--stack <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 <name>` 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<String> {
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 <name>` command.
///
/// Creates `~/huskies/<name>/`, 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 `<path>`".
pub async fn handle_new_project(
name: &str,
projects_store: &Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
config_dir: &Path,
) -> String {
let name = name.trim();
if name.is_empty() {
return "Usage: `new project <name>` — 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/<name>/
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 <name>` 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())
);
}
}
+1
View File
@@ -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),
+1
View File
@@ -366,6 +366,7 @@ async fn main() -> Result<(), std::io::Error> {
None,
vec![],
std::collections::BTreeMap::new(),
None,
timer_store_for_bot,
None,
);
+6
View File
@@ -500,11 +500,13 @@ pub type ActiveProject = std::sync::Arc<tokio::sync::RwLock<String>>;
/// 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<String>,
gateway_project_urls: BTreeMap<String, String>,
gateway_projects_store: std::sync::Arc<tokio::sync::RwLock<BTreeMap<String, ProjectEntry>>>,
port: u16,
gateway_event_tx: Option<tokio::sync::broadcast::Sender<super::GatewayStatusEvent>>,
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::<crate::http::context::PermissionForward>();
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,
+1
View File
@@ -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),