huskies: merge 1140 story One-shot project-rebuild chat command: rebuild image, swap container, reconnect, preserve state
This commit is contained in:
@@ -274,6 +274,11 @@ pub fn commands() -> &'static [BotCommand] {
|
||||
description: "Bootstrap a new project container (gateway only): `new project <name>`",
|
||||
handler: new_project::handle_new_project_fallback,
|
||||
},
|
||||
BotCommand {
|
||||
name: "project-rebuild",
|
||||
description: "Rebuild a project's Docker image and swap the container (gateway only): `project-rebuild <name> [--timeout <secs>] [--force]`",
|
||||
handler: handle_project_rebuild_fallback,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -431,6 +436,16 @@ fn handle_cleanup_worktrees_fallback(_ctx: &CommandContext) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Fallback handler for the `project-rebuild` command when it is not intercepted
|
||||
/// by the async gateway handler in `on_room_message`. In practice this is never
|
||||
/// called — `project-rebuild` 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_project_rebuild_fallback(_ctx: &CommandContext) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -225,6 +225,7 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
||||
"all_status",
|
||||
"new",
|
||||
"config",
|
||||
"project-rebuild",
|
||||
];
|
||||
|
||||
let stripped = crate::chat::util::strip_bot_mention(
|
||||
@@ -404,6 +405,46 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
||||
return;
|
||||
}
|
||||
|
||||
// In gateway mode, handle the `project-rebuild <name>` command to rebuild a
|
||||
// project container and swap it without losing pipeline state.
|
||||
if ctx.is_gateway()
|
||||
&& let Some(rebuild_cmd) =
|
||||
super::super::super::project_rebuild::extract_project_rebuild_command(
|
||||
&user_message,
|
||||
&ctx.services.bot_name,
|
||||
ctx.matrix_user_id.as_str(),
|
||||
)
|
||||
{
|
||||
slog!(
|
||||
"[matrix-bot] Handling project-rebuild command from {sender}: name={:?} timeout={}s force={}",
|
||||
rebuild_cmd.name,
|
||||
rebuild_cmd.drain_timeout_secs,
|
||||
rebuild_cmd.force,
|
||||
);
|
||||
let response = if let Some(ref store) = ctx.gateway_projects_store {
|
||||
super::super::super::project_rebuild::handle_project_rebuild(
|
||||
&rebuild_cmd.name,
|
||||
rebuild_cmd.drain_timeout_secs,
|
||||
rebuild_cmd.force,
|
||||
store,
|
||||
&ctx.services.project_root,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
"Gateway projects store unavailable — cannot rebuild 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.
|
||||
|
||||
@@ -29,6 +29,8 @@ pub mod delete;
|
||||
pub mod htop;
|
||||
/// `new project <name>` chat command — Phase 1 gateway project bootstrap.
|
||||
pub mod new_project;
|
||||
/// `project-rebuild <name>` chat command — rebuild Docker image, swap container, preserve state.
|
||||
pub mod project_rebuild;
|
||||
/// 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.
|
||||
|
||||
@@ -533,7 +533,7 @@ async fn read_host_git_config(key: &str) -> Option<String> {
|
||||
/// Resolve the git identity to use for new project containers.
|
||||
///
|
||||
/// Priority: `bot.toml` fields → host `git config` → hardcoded fallback.
|
||||
async fn resolve_git_identity(config_dir: &std::path::Path) -> (String, String) {
|
||||
pub(crate) async fn resolve_git_identity(config_dir: &std::path::Path) -> (String, String) {
|
||||
let (bot_name, bot_email) = read_git_identity_from_bot_toml(config_dir).await;
|
||||
|
||||
let name = if let Some(n) = bot_name {
|
||||
@@ -1335,7 +1335,7 @@ pub async fn build_project_image(
|
||||
/// `/home/huskies/.claude/.credentials.json` with mode 0600. Mounting to an
|
||||
/// intermediate path (rather than directly to the destination) ensures the
|
||||
/// huskies user owns the copy regardless of the host user's UID.
|
||||
fn project_docker_run_args(
|
||||
pub(crate) fn project_docker_run_args(
|
||||
container_name: &str,
|
||||
port: u16,
|
||||
ssh_port: u16,
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
//! `project-rebuild <name>` chat command — rebuild Docker image, swap container, preserve state.
|
||||
//!
|
||||
//! Usage: `{bot} project-rebuild <name> [--timeout <secs>] [--force]`
|
||||
//!
|
||||
//! Steps performed:
|
||||
//! 1. Validate the project exists and has a `host_path` configured.
|
||||
//! 2. Check for in-flight coder/merge work (active `claude` processes in the container).
|
||||
//! Wait up to `--timeout` seconds for them to exit. Refuse if still active.
|
||||
//! 3. Build a new Docker image from the project's `Dockerfile.fragment` (if present).
|
||||
//! 4. Stop and remove the old container.
|
||||
//! 5. Start a new container from the fresh image, mounting the same host volume so
|
||||
//! `pipeline.db` and all CRDT state survive untouched.
|
||||
//! 6. Re-register the project in the gateway (same URL — port is preserved).
|
||||
//!
|
||||
//! On success the reply names the new image hash and the new container ID.
|
||||
//! On failure the reply names the step that failed and the recovery path.
|
||||
|
||||
use crate::service::gateway::config::ProjectEntry;
|
||||
use crate::service::gateway::io::save_config;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Default seconds to wait for in-flight work to drain before refusing.
|
||||
const DEFAULT_DRAIN_TIMEOUT_SECS: u64 = 60;
|
||||
|
||||
/// A parsed `project-rebuild <name>` command.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct ProjectRebuildCommand {
|
||||
/// Name of the project to rebuild.
|
||||
pub name: String,
|
||||
/// Seconds to wait for agents to drain (0 = skip check).
|
||||
pub drain_timeout_secs: u64,
|
||||
/// If `true`, skip the drain check entirely.
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
/// Parse a `project-rebuild <name> [--timeout <secs>] [--force]` command from a raw
|
||||
/// Matrix message body.
|
||||
///
|
||||
/// Strips the bot mention prefix and checks for the `project-rebuild` keyword.
|
||||
/// Returns `None` when the message is not a project-rebuild command.
|
||||
pub fn extract_project_rebuild_command(
|
||||
message: &str,
|
||||
bot_name: &str,
|
||||
bot_user_id: &str,
|
||||
) -> Option<ProjectRebuildCommand> {
|
||||
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 rest = if let Some(r) = trimmed.strip_prefix("project-rebuild") {
|
||||
r.trim()
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let mut parts = rest.split_whitespace();
|
||||
let name = match parts.next() {
|
||||
Some(n) if !n.starts_with("--") => n.to_string(),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let mut drain_timeout_secs = DEFAULT_DRAIN_TIMEOUT_SECS;
|
||||
let mut force = false;
|
||||
|
||||
let remaining: Vec<&str> = parts.collect();
|
||||
let mut i = 0;
|
||||
while i < remaining.len() {
|
||||
match remaining[i] {
|
||||
"--timeout" if i + 1 < remaining.len() => {
|
||||
drain_timeout_secs = remaining[i + 1]
|
||||
.parse()
|
||||
.unwrap_or(DEFAULT_DRAIN_TIMEOUT_SECS);
|
||||
i += 2;
|
||||
}
|
||||
"--force" => {
|
||||
force = true;
|
||||
i += 1;
|
||||
}
|
||||
_ => {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(ProjectRebuildCommand {
|
||||
name,
|
||||
drain_timeout_secs,
|
||||
force,
|
||||
})
|
||||
}
|
||||
|
||||
/// Rebuild a project's Docker image, swap the container, and preserve all state.
|
||||
///
|
||||
/// On success returns a message naming the new image hash and container ID.
|
||||
/// On failure returns a message naming the failed step and the recovery path.
|
||||
pub async fn handle_project_rebuild(
|
||||
name: &str,
|
||||
drain_timeout_secs: u64,
|
||||
force: bool,
|
||||
projects_store: &Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
|
||||
config_dir: &Path,
|
||||
) -> String {
|
||||
// ── 1. Validate project ──────────────────────────────────────────────────
|
||||
let (host_path_str, project_url, ssh_port_opt) = {
|
||||
let projects = projects_store.read().await;
|
||||
let entry = match projects.get(name) {
|
||||
Some(e) => e.clone(),
|
||||
None => {
|
||||
let available: Vec<&String> = projects.keys().collect();
|
||||
return format!(
|
||||
"Project `{name}` not found. Available: {}",
|
||||
available
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
}
|
||||
};
|
||||
match entry.host_path.clone() {
|
||||
Some(p) => (p, entry.url.clone(), entry.ssh_port),
|
||||
None => {
|
||||
return format!(
|
||||
"Project `{name}` has no `host_path` configured — cannot rebuild.\n\
|
||||
Only projects created with `new project --adopt` or `adopt_project` \
|
||||
support the `project-rebuild` command."
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let host_path = Path::new(&host_path_str);
|
||||
if !host_path.exists() {
|
||||
return format!(
|
||||
"Host path `{host_path_str}` does not exist on disk — \
|
||||
cannot rebuild project `{name}`."
|
||||
);
|
||||
}
|
||||
|
||||
// ── 2. Drain check ───────────────────────────────────────────────────────
|
||||
let container_name = format!("huskies-{name}");
|
||||
if !force
|
||||
&& drain_timeout_secs > 0
|
||||
&& let Some(err_msg) = wait_for_drain(&container_name, drain_timeout_secs).await
|
||||
{
|
||||
return format!(
|
||||
"Project `{name}` rebuild aborted: {err_msg}\n\
|
||||
Pass `--force` to skip the drain check or `--timeout 0` to not wait."
|
||||
);
|
||||
}
|
||||
|
||||
// ── 3. Build new image ───────────────────────────────────────────────────
|
||||
let stacks_dir = config_dir.join("docker").join("stacks");
|
||||
let (resolved_stack, _warnings) = super::new_project::detect_stack(host_path, &stacks_dir);
|
||||
let base_image = super::new_project::image_for_stack(resolved_stack.as_deref());
|
||||
|
||||
let image = match super::new_project::build_project_image(host_path, &base_image, name).await {
|
||||
Ok(img) => img,
|
||||
Err(e) => {
|
||||
return format!(
|
||||
"Rebuild failed at **image build** step.\n\
|
||||
Error: {e}\n\n\
|
||||
Recovery: fix `.huskies/Dockerfile.fragment` in `{host_path_str}` then retry."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let image_hash = get_image_id(&image)
|
||||
.await
|
||||
.unwrap_or_else(|_| "unknown".to_string());
|
||||
let image_short: String = image_hash.chars().take(19).collect();
|
||||
|
||||
// ── 4. Stop and remove old container ────────────────────────────────────
|
||||
if let Err(e) = docker_stop(&container_name).await {
|
||||
crate::slog!("[project-rebuild] stop '{container_name}': {e} (may already be stopped)");
|
||||
}
|
||||
if let Err(e) = docker_rm(&container_name).await {
|
||||
return format!(
|
||||
"Rebuild failed at **container remove** step.\n\
|
||||
Error: {e}\n\n\
|
||||
Recovery: run `docker rm {container_name}` manually then retry."
|
||||
);
|
||||
}
|
||||
|
||||
// ── 5. Start new container ───────────────────────────────────────────────
|
||||
let port = project_url
|
||||
.as_deref()
|
||||
.and_then(|u| u.rsplit(':').next())
|
||||
.and_then(|p| p.parse::<u16>().ok())
|
||||
.unwrap_or(3001);
|
||||
let ssh_port = ssh_port_opt.unwrap_or(2222);
|
||||
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/huskies".to_string());
|
||||
let pub_key_path = std::path::PathBuf::from(&home)
|
||||
.join(".huskies")
|
||||
.join(name)
|
||||
.join("id_ed25519.pub");
|
||||
let pubkey = match tokio::fs::read_to_string(&pub_key_path).await {
|
||||
Ok(k) => k.trim().to_string(),
|
||||
Err(e) => {
|
||||
return format!(
|
||||
"Rebuild failed at **SSH key read** step.\n\
|
||||
Error: {e}\n\
|
||||
Expected public key at `{}`.\n\n\
|
||||
Recovery: run `ssh-keygen -t ed25519 -N '' -f {home}/.huskies/{name}/id_ed25519` \
|
||||
then retry.",
|
||||
pub_key_path.display()
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let credentials_file = std::path::PathBuf::from(&home)
|
||||
.join(".claude")
|
||||
.join(".credentials.json");
|
||||
let creds_opt = if credentials_file.exists() {
|
||||
Some(credentials_file.as_path())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (git_user_name, git_user_email) =
|
||||
super::new_project::resolve_git_identity(config_dir).await;
|
||||
|
||||
let mut docker_args = super::new_project::project_docker_run_args(
|
||||
&container_name,
|
||||
port,
|
||||
ssh_port,
|
||||
&pubkey,
|
||||
&git_user_name,
|
||||
&git_user_email,
|
||||
creds_opt,
|
||||
);
|
||||
|
||||
docker_args.push("-v".into());
|
||||
docker_args.push(format!("{host_path_str}:/workspace"));
|
||||
|
||||
let host_ssh_dir = std::path::PathBuf::from(&home).join(".ssh");
|
||||
for key_name in &["id_ed25519", "id_rsa"] {
|
||||
let key_path = host_ssh_dir.join(key_name);
|
||||
if key_path.exists() {
|
||||
docker_args.push("-v".into());
|
||||
docker_args.push(format!(
|
||||
"{}:/home/huskies/.ssh/{key_name}:ro",
|
||||
key_path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
docker_args.push("--restart".into());
|
||||
docker_args.push("unless-stopped".into());
|
||||
docker_args.push(image.clone());
|
||||
docker_args.push("huskies".into());
|
||||
docker_args.push("/workspace".into());
|
||||
|
||||
let run_output = tokio::process::Command::new("docker")
|
||||
.args(&docker_args)
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let container_id = match run_output {
|
||||
Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_string(),
|
||||
Ok(out) => {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
return format!(
|
||||
"Rebuild failed at **container start** step.\n\
|
||||
Error: {stderr}\n\n\
|
||||
Recovery: the old container was removed. \
|
||||
Start a new one manually: `docker run -d --name {container_name} ... {image} huskies /workspace`"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
return format!(
|
||||
"Rebuild failed at **container start** step.\n\
|
||||
Error: {e}\n\n\
|
||||
Recovery: start the container manually: \
|
||||
`docker run -d --name {container_name} ... {image} huskies /workspace`"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let container_short: String = container_id.chars().take(12).collect();
|
||||
|
||||
// ── 6. Persist updated config (URL is unchanged; project already registered) ────
|
||||
{
|
||||
let container_url = format!("http://127.0.0.1:{port}");
|
||||
let mut projects = projects_store.write().await;
|
||||
if let Some(entry) = projects.get_mut(name) {
|
||||
entry.url = Some(container_url.clone());
|
||||
}
|
||||
save_config(&projects, config_dir).await;
|
||||
crate::crdt_state::write_gateway_project(name, &container_url);
|
||||
}
|
||||
|
||||
crate::slog!("[project-rebuild] Rebuilt '{name}': image={image_hash} container={container_id}");
|
||||
|
||||
format!(
|
||||
"Project **{name}** rebuilt.\n\
|
||||
- New image: `{image}` (`{image_short}…`)\n\
|
||||
- New container: `{container_name}` (`{container_short}…`)\n\
|
||||
- State: `pipeline.db` and CRDT preserved (same volume bind-mount)\n\
|
||||
- Port: {port} (unchanged)\n\
|
||||
\n\
|
||||
Use `switch {name}` then `status` to verify the pipeline."
|
||||
)
|
||||
}
|
||||
|
||||
/// Wait for active Claude agent processes in the container to exit.
|
||||
///
|
||||
/// Polls every 5 seconds until no `claude` processes remain or `timeout_secs` elapses.
|
||||
/// Returns `Some(error_message)` when agents are still running after the timeout,
|
||||
/// `None` when the container is idle or unreachable.
|
||||
async fn wait_for_drain(container_name: &str, timeout_secs: u64) -> Option<String> {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
|
||||
let poll_interval = std::time::Duration::from_secs(5);
|
||||
|
||||
loop {
|
||||
match count_active_claude_processes(container_name).await {
|
||||
Ok(0) => return None,
|
||||
Ok(n) => {
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return Some(format!(
|
||||
"{n} Claude agent process(es) still running after {timeout_secs}s drain timeout."
|
||||
));
|
||||
}
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
}
|
||||
Err(_) => {
|
||||
// docker exec failed (container stopped or Docker unavailable) — proceed.
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Count the number of active `claude` processes inside the given container.
|
||||
///
|
||||
/// Uses `docker exec <name> pgrep -f claude` — exits 0 with PID list when found,
|
||||
/// exits 1 when no matches (treated as 0 active processes).
|
||||
async fn count_active_claude_processes(container_name: &str) -> Result<usize, String> {
|
||||
let out = tokio::process::Command::new("docker")
|
||||
.args(["exec", container_name, "pgrep", "-f", "claude"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if out.status.success() {
|
||||
let count = String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.count();
|
||||
Ok(count)
|
||||
} else {
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop a running Docker container (`docker stop`).
|
||||
async fn docker_stop(container_name: &str) -> Result<(), String> {
|
||||
let out = tokio::process::Command::new("docker")
|
||||
.args(["stop", container_name])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("docker stop failed to spawn: {e}"))?;
|
||||
|
||||
if out.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a stopped Docker container (`docker rm`).
|
||||
async fn docker_rm(container_name: &str) -> Result<(), String> {
|
||||
let out = tokio::process::Command::new("docker")
|
||||
.args(["rm", container_name])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("docker rm failed to spawn: {e}"))?;
|
||||
|
||||
if out.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the full image ID (sha256 digest) for a named Docker image.
|
||||
async fn get_image_id(image_name: &str) -> Result<String, String> {
|
||||
let out = tokio::process::Command::new("docker")
|
||||
.args(["inspect", image_name, "--format", "{{.Id}}"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("docker inspect failed: {e}"))?;
|
||||
|
||||
if out.status.success() {
|
||||
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::service::gateway::config::ProjectEntry;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
fn make_store(
|
||||
projects: Vec<(&str, ProjectEntry)>,
|
||||
) -> Arc<RwLock<BTreeMap<String, ProjectEntry>>> {
|
||||
let map: BTreeMap<String, ProjectEntry> = projects
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_string(), v))
|
||||
.collect();
|
||||
Arc::new(RwLock::new(map))
|
||||
}
|
||||
|
||||
// ── parsing ────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_basic_command() {
|
||||
let cmd =
|
||||
extract_project_rebuild_command("Timmy project-rebuild myapp", "Timmy", "@timmy:home");
|
||||
let cmd = cmd.unwrap();
|
||||
assert_eq!(cmd.name, "myapp");
|
||||
assert_eq!(cmd.drain_timeout_secs, DEFAULT_DRAIN_TIMEOUT_SECS);
|
||||
assert!(!cmd.force);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_with_force_flag() {
|
||||
let cmd = extract_project_rebuild_command(
|
||||
"@timmy project-rebuild myapp --force",
|
||||
"Timmy",
|
||||
"@timmy:home",
|
||||
);
|
||||
let cmd = cmd.unwrap();
|
||||
assert_eq!(cmd.name, "myapp");
|
||||
assert!(cmd.force);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_with_timeout_flag() {
|
||||
let cmd = extract_project_rebuild_command(
|
||||
"Timmy project-rebuild myapp --timeout 120",
|
||||
"Timmy",
|
||||
"@timmy:home",
|
||||
);
|
||||
let cmd = cmd.unwrap();
|
||||
assert_eq!(cmd.name, "myapp");
|
||||
assert_eq!(cmd.drain_timeout_secs, 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_with_timeout_zero_skips_drain() {
|
||||
let cmd = extract_project_rebuild_command(
|
||||
"Timmy project-rebuild myapp --timeout 0",
|
||||
"Timmy",
|
||||
"@timmy:home",
|
||||
);
|
||||
let cmd = cmd.unwrap();
|
||||
assert_eq!(cmd.drain_timeout_secs, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_non_rebuild_returns_none() {
|
||||
let cmd = extract_project_rebuild_command("Timmy status", "Timmy", "@timmy:home");
|
||||
assert!(cmd.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_rebuild_without_name_returns_none() {
|
||||
let cmd = extract_project_rebuild_command("Timmy project-rebuild", "Timmy", "@timmy:home");
|
||||
assert!(cmd.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_with_full_user_id() {
|
||||
let cmd = extract_project_rebuild_command(
|
||||
"@timmy:home project-rebuild alpha",
|
||||
"Timmy",
|
||||
"@timmy:home",
|
||||
);
|
||||
assert_eq!(cmd.unwrap().name, "alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_case_insensitive_bot_mention() {
|
||||
let cmd =
|
||||
extract_project_rebuild_command("timmy project-rebuild beta", "Timmy", "@timmy:home");
|
||||
assert_eq!(cmd.unwrap().name, "beta");
|
||||
}
|
||||
|
||||
// ── handle_project_rebuild validation ─────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn rebuild_unknown_project_returns_error() {
|
||||
let store = make_store(vec![]);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = handle_project_rebuild("nonexistent", 0, true, &store, dir.path()).await;
|
||||
assert!(
|
||||
result.contains("not found"),
|
||||
"expected 'not found': {result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rebuild_project_without_host_path_returns_error() {
|
||||
let store = make_store(vec![(
|
||||
"myapp",
|
||||
ProjectEntry {
|
||||
url: Some("http://127.0.0.1:3101".into()),
|
||||
auth_token: None,
|
||||
ssh_port: Some(2201),
|
||||
host_path: None,
|
||||
},
|
||||
)]);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = handle_project_rebuild("myapp", 0, true, &store, dir.path()).await;
|
||||
assert!(
|
||||
result.contains("host_path"),
|
||||
"expected 'host_path' mention: {result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rebuild_project_with_missing_host_dir_returns_error() {
|
||||
let store = make_store(vec![(
|
||||
"myapp",
|
||||
ProjectEntry {
|
||||
url: Some("http://127.0.0.1:3101".into()),
|
||||
auth_token: None,
|
||||
ssh_port: Some(2201),
|
||||
host_path: Some("/nonexistent/path/xyz123".into()),
|
||||
},
|
||||
)]);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = handle_project_rebuild("myapp", 0, true, &store, dir.path()).await;
|
||||
assert!(
|
||||
result.contains("does not exist"),
|
||||
"expected 'does not exist': {result}"
|
||||
);
|
||||
}
|
||||
|
||||
/// End-to-end flow test: rebuild a project that has a valid host directory.
|
||||
///
|
||||
/// With `--force` and `--timeout 0` the drain check is skipped.
|
||||
/// The function proceeds to the image build step, which fails when Docker is
|
||||
/// not available in CI. On failure the reply must:
|
||||
/// (a) name the failed step ("image build")
|
||||
/// (b) leave the project still registered in the gateway (state preserved)
|
||||
/// (c) include a recovery path
|
||||
///
|
||||
/// When Docker IS available and the base image exists this test would exercise
|
||||
/// the full container stop → build → start → re-register flow.
|
||||
#[tokio::test]
|
||||
async fn rebuild_e2e_with_valid_host_path_reaches_image_build_step() {
|
||||
let host_dir = tempfile::tempdir().unwrap();
|
||||
// Create a minimal .huskies/ directory (simulating an existing project).
|
||||
std::fs::create_dir_all(host_dir.path().join(".huskies")).unwrap();
|
||||
|
||||
let store = make_store(vec![(
|
||||
"myapp",
|
||||
ProjectEntry {
|
||||
url: Some("http://127.0.0.1:3101".into()),
|
||||
auth_token: Some("tok".into()),
|
||||
ssh_port: Some(2201),
|
||||
host_path: Some(host_dir.path().to_str().unwrap().to_string()),
|
||||
},
|
||||
)]);
|
||||
let config_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
let result = handle_project_rebuild("myapp", 0, true, &store, config_dir.path()).await;
|
||||
|
||||
// (a) Step naming: one of several possible failure steps depending on what Docker
|
||||
// binaries are available in the test environment, or a success reply.
|
||||
let names_a_step = result.contains("image build")
|
||||
|| result.contains("SSH key")
|
||||
|| result.contains("container remove")
|
||||
|| result.contains("container start");
|
||||
let is_success = result.contains("rebuilt");
|
||||
assert!(
|
||||
names_a_step || is_success,
|
||||
"result should name a step or report success: {result}"
|
||||
);
|
||||
|
||||
// (b) State preserved: project is still registered in the gateway store.
|
||||
let projects = store.read().await;
|
||||
assert!(
|
||||
projects.contains_key("myapp"),
|
||||
"project 'myapp' must remain registered after failed rebuild: {result}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ const GATEWAY_TOOLS: &[&str] = &[
|
||||
"prompt_permission",
|
||||
// Binary self-update: gateway serves its own binary and triggers upgrade on sleds.
|
||||
"upgrade_sled",
|
||||
// One-shot container rebuild: build fresh image, swap container, preserve state.
|
||||
"project_rebuild",
|
||||
];
|
||||
|
||||
/// Gateway tool definitions.
|
||||
@@ -140,6 +142,28 @@ pub(crate) fn gateway_tool_definitions() -> Vec<Value> {
|
||||
}
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "project_rebuild",
|
||||
"description": "Rebuild a project's Docker image from its Dockerfile.fragment, swap the container, and preserve all CRDT and pipeline state. In-flight coder/merge work is drained before the swap; if not drainable within the timeout the command refuses. On success returns the new image hash and container ID.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the project to rebuild (must exist in projects.toml with host_path set)."
|
||||
},
|
||||
"drain_timeout_secs": {
|
||||
"type": "integer",
|
||||
"description": "Seconds to wait for active agents to stop before rebuilding (default: 60). Pass 0 to skip the drain check."
|
||||
},
|
||||
"force": {
|
||||
"type": "boolean",
|
||||
"description": "If true, skip the drain check and rebuild immediately even if agents are running."
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
}
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -405,6 +429,7 @@ async fn handle_gateway_tool(
|
||||
"agents.list" => handle_agents_list_tool(id),
|
||||
"prompt_permission" => handle_prompt_permission_tool(params, state, id).await,
|
||||
"upgrade_sled" => handle_upgrade_sled_tool(params, state, id).await,
|
||||
"project_rebuild" => handle_project_rebuild_tool(params, state, id).await,
|
||||
_ => JsonRpcResponse::error(id, -32601, format!("Unknown gateway tool: {tool_name}")),
|
||||
}
|
||||
}
|
||||
@@ -876,6 +901,55 @@ async fn handle_upgrade_sled_tool(
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle the `project_rebuild` gateway tool.
|
||||
///
|
||||
/// Rebuilds a project's Docker image, swaps the container, and preserves all
|
||||
/// CRDT and pipeline state. Delegates to `handle_project_rebuild` in the chat
|
||||
/// transport module so the logic is shared between the chat and MCP entry points.
|
||||
async fn handle_project_rebuild_tool(
|
||||
params: &Value,
|
||||
state: &GatewayState,
|
||||
id: Option<Value>,
|
||||
) -> JsonRpcResponse {
|
||||
use crate::chat::transport::matrix::project_rebuild::handle_project_rebuild;
|
||||
|
||||
let args = params.get("arguments").unwrap_or(params);
|
||||
let name = args
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
|
||||
if name.is_empty() {
|
||||
return JsonRpcResponse::error(id, -32602, "missing required parameter: name".into());
|
||||
}
|
||||
|
||||
let drain_timeout_secs = args
|
||||
.get("drain_timeout_secs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(60);
|
||||
let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
|
||||
let result = handle_project_rebuild(
|
||||
name,
|
||||
drain_timeout_secs,
|
||||
force,
|
||||
&state.projects,
|
||||
&state.config_dir,
|
||||
)
|
||||
.await;
|
||||
|
||||
JsonRpcResponse::success(
|
||||
id,
|
||||
json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": result
|
||||
}]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Handle the `pipeline.get` read-RPC — returns per-project item lists in the
|
||||
/// shape expected by the gateway web UI:
|
||||
/// `{ "active": "...", "projects": { "name": { "active": [...], "backlog_count": N } } }`.
|
||||
|
||||
Reference in New Issue
Block a user