huskies: merge 1140 story One-shot project-rebuild chat command: rebuild image, swap container, reconnect, preserve state

This commit is contained in:
dave
2026-05-18 14:50:04 +00:00
parent 4aaf7dbdc6
commit b1dec36e1c
6 changed files with 738 additions and 2 deletions
+15
View File
@@ -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.
+2
View File
@@ -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}"
);
}
}
+74
View File
@@ -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 } } }`.