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:
@@ -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,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -366,6 +366,7 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
None,
|
||||
vec![],
|
||||
std::collections::BTreeMap::new(),
|
||||
None,
|
||||
timer_store_for_bot,
|
||||
None,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user