use super::error::Result;
use super::r#trait::{Tool, ToolCapability, ToolExecutionContext, ToolResult};
use crate::brain::agent::{ProgressCallback, ProgressEvent};
use crate::utils::install::{InstallMethod, binary_name, platform_suffix};
use async_trait::async_trait;
use serde_json::Value;
const GITHUB_API: &str = "https://api.github.com/repos/adolfousier/opencrabs/releases/latest";
pub(crate) fn diagnose_releases_latest_status(
status: reqwest::StatusCode,
body_excerpt: &str,
ratelimit_remaining: Option<&str>,
ratelimit_reset: Option<&str>,
) -> String {
let code = status.as_u16();
let body_tail = if body_excerpt.trim().is_empty() {
String::new()
} else {
format!(" — API said: {}", body_excerpt.trim())
};
let ratelimit_tail = match (ratelimit_remaining, ratelimit_reset) {
(Some(r), Some(reset)) => {
format!(" [x-ratelimit-remaining={r}, x-ratelimit-reset={reset}]")
}
(Some(r), None) => format!(" [x-ratelimit-remaining={r}]"),
_ => String::new(),
};
match code {
404 => format!(
"GitHub returned 404 for releases/latest — no published \
(non-draft, non-prerelease) release exists for this repo \
at this moment, or there's a brief publish-propagation lag. \
Try again in a minute.{body_tail}{ratelimit_tail}"
),
403 | 429 => format!(
"GitHub rate limit hit ({code}) — unauthenticated requests \
are capped at 60/hr per IP. Wait an hour, or set GITHUB_TOKEN \
in your env to raise the cap to 5000/hr if you share this \
IP.{body_tail}{ratelimit_tail}"
),
500..=599 => format!(
"GitHub API returned {code} — server-side issue, retry in a \
few minutes.{body_tail}"
),
_ => format!("GitHub API returned {status}.{body_tail}{ratelimit_tail}"),
}
}
pub(crate) const SYSTEMD_UNIT_PATTERN: &str = "opencrabs*.service";
pub(crate) fn build_systemd_restart_command(pid: u32, user: bool) -> std::process::Command {
let unit_name = format!("opencrabs-evolve-{pid}");
let mut cmd = std::process::Command::new("systemd-run");
let mut args = vec![];
if user {
args.push("--user".to_string());
}
args.push("--on-active=3".to_string());
args.push(format!("--unit={unit_name}"));
args.push("systemctl".to_string());
if user {
args.push("--user".to_string());
}
args.push("restart".to_string());
args.push(SYSTEMD_UNIT_PATTERN.to_string());
cmd.args(&args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
cmd
}
pub(crate) const EVOLVE_UNIT_GLOB: &str = "opencrabs-evolve-*.service";
pub(crate) fn build_systemd_cleanup_command(user: bool) -> std::process::Command {
let mut cmd = std::process::Command::new("systemctl");
if user {
cmd.arg("--user");
}
cmd.arg("reset-failed")
.arg(EVOLVE_UNIT_GLOB)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
cmd
}
pub(crate) fn count_matching_systemd_units(pattern: &str, user: bool) -> Option<usize> {
let mut cmd = std::process::Command::new("systemctl");
cmd.args(["list-units", "--no-legend", "--no-pager"]);
if user {
cmd.arg("--user");
}
cmd.arg(pattern);
cmd.stderr(std::process::Stdio::null());
let output = cmd.output().ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
Some(stdout.lines().filter(|l| !l.trim().is_empty()).count())
}
pub async fn check_for_update() -> Option<String> {
let current_version = crate::VERSION;
let client = reqwest::Client::new();
let resp = match client
.get(GITHUB_API)
.header("User-Agent", format!("opencrabs/{}", current_version))
.header("Accept", "application/vnd.github+json")
.send()
.await
{
Ok(r) => r,
Err(e) => {
tracing::warn!(
target: "evolve",
url = GITHUB_API,
error = %e,
"background update check failed to reach GitHub"
);
return None;
}
};
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
let body_excerpt: String = body.chars().take(300).collect();
tracing::warn!(
target: "evolve",
url = GITHUB_API,
%status,
body_excerpt,
"background update check: releases/latest returned non-2xx"
);
return None;
}
let release: serde_json::Value = match resp.json().await {
Ok(v) => v,
Err(e) => {
tracing::warn!(
target: "evolve",
url = GITHUB_API,
error = %e,
"background update check: failed to parse releases/latest JSON"
);
return None;
}
};
let latest_tag = match release["tag_name"].as_str() {
Some(t) => t,
None => {
tracing::warn!(
target: "evolve",
"background update check: releases/latest payload missing tag_name"
);
return None;
}
};
let latest_version = latest_tag.strip_prefix('v').unwrap_or(latest_tag);
if !is_newer(latest_version, current_version) {
return None;
}
if let Some(source_version) = source_cargo_version()
&& source_version == latest_version
{
return None;
}
if matches!(InstallMethod::detect(), InstallMethod::PrebuiltBinary)
&& !has_platform_asset(&release, latest_tag)
{
tracing::debug!(
"Release {} exists but no asset for this platform yet",
latest_tag
);
return None;
}
Some(latest_version.to_string())
}
pub(crate) fn has_platform_asset(release: &serde_json::Value, tag: &str) -> bool {
let suffix = match platform_suffix() {
Some(s) => s,
None => return false,
};
let ext = if std::env::consts::OS == "windows" {
"zip"
} else {
"tar.gz"
};
let expected = format!("opencrabs-{}-{}.{}", tag, suffix, ext);
let legacy = format!("opencrabs-{}.{}", suffix, ext);
release["assets"]
.as_array()
.map(|arr| {
arr.iter().any(|a| {
let name = a["name"].as_str().unwrap_or("");
name == expected || name == legacy
})
})
.unwrap_or(false)
}
pub fn is_newer(latest: &str, current: &str) -> bool {
let parse = |v: &str| -> Vec<u64> { v.split('.').filter_map(|s| s.parse().ok()).collect() };
let l = parse(latest);
let c = parse(current);
l > c
}
fn source_cargo_version() -> Option<String> {
let exe = std::env::current_exe().ok()?;
let target_dir = exe.parent()?;
let repo_root = target_dir.parent()?.parent()?;
let cargo_toml = repo_root.join("Cargo.toml");
let content = std::fs::read_to_string(&cargo_toml).ok()?;
let table: toml::Table = content.parse().ok()?;
table
.get("package")?
.get("version")?
.as_str()
.map(String::from)
}
async fn health_check_binary(path: &std::path::Path) -> std::result::Result<(), String> {
let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
tracing::info!(
target: "evolve",
path = %path.display(),
size = file_size,
"evolve: running `<binary> --version` health check"
);
let mut attempt = 0u32;
let result = loop {
attempt += 1;
let r = tokio::time::timeout(
std::time::Duration::from_secs(10),
tokio::process::Command::new(path)
.arg("--version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output(),
)
.await;
if let Ok(Err(ref e)) = r
&& attempt < 5
&& matches!(e.raw_os_error(), Some(2) | Some(26))
{
tracing::warn!(
target: "evolve",
attempt,
os_error = ?e.raw_os_error(),
"evolve: fresh binary not exec-able yet, retrying health check"
);
tokio::time::sleep(std::time::Duration::from_millis(200 * attempt as u64)).await;
continue;
}
break r;
};
match result {
Ok(Ok(output)) if output.status.success() => Ok(()),
Ok(Ok(output)) => {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr_snippet: String = stderr.chars().take(200).collect();
tracing::warn!(
target: "evolve",
path = %path.display(),
exit_status = %output.status,
size = file_size,
stderr_excerpt = %stderr_snippet,
"evolve: health check exited non-zero"
);
Err(format!(
"exited with {} (binary: {} bytes, platform: {}/{}{})",
output.status,
file_size,
std::env::consts::OS,
std::env::consts::ARCH,
if stderr_snippet.is_empty() {
String::new()
} else {
format!(", stderr: {}", stderr_snippet)
}
))
}
Ok(Err(e)) => {
tracing::warn!(
target: "evolve",
path = %path.display(),
error = %e,
size = file_size,
"evolve: health check failed to spawn the binary"
);
Err(format!(
"failed to spawn: {e} (binary: {file_size} bytes, platform: {}/{})",
std::env::consts::OS,
std::env::consts::ARCH
))
}
Err(_) => {
tracing::warn!(
target: "evolve",
path = %path.display(),
size = file_size,
"evolve: health check timed out after 10s"
);
Err(format!("timed out after 10s (binary: {file_size} bytes)"))
}
}
}
async fn get_binary_migration_count(path: &std::path::Path) -> std::result::Result<usize, String> {
let result = tokio::time::timeout(
std::time::Duration::from_secs(10),
tokio::process::Command::new(path)
.arg("print-migration-count")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output(),
)
.await;
match result {
Ok(Ok(output)) if output.status.success() => {
let stdout = String::from_utf8_lossy(&output.stdout);
let count = stdout
.trim()
.parse::<usize>()
.map_err(|e| format!("could not parse migration count from '{stdout}': {e}"))?;
Ok(count)
}
Ok(Ok(output)) => {
let stderr = String::from_utf8_lossy(&output.stderr);
let snippet: String = stderr.chars().take(200).collect();
Err(format!(
"print-migration-count exited with {}: {}",
output.status,
if snippet.is_empty() {
"no stderr"
} else {
&snippet
}
))
}
Ok(Err(e)) => Err(format!("failed to spawn binary: {e}")),
Err(_) => Err("timed out after 10s".into()),
}
}
pub struct EvolveTool {
progress: Option<ProgressCallback>,
}
impl EvolveTool {
pub fn new(progress: Option<ProgressCallback>) -> Self {
Self { progress }
}
}
static EVOLVE_IN_PROGRESS: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
struct EvolveInProgressGuard;
impl Drop for EvolveInProgressGuard {
fn drop(&mut self) {
EVOLVE_IN_PROGRESS.store(false, std::sync::atomic::Ordering::Release);
}
}
#[async_trait]
impl Tool for EvolveTool {
fn name(&self) -> &str {
"evolve"
}
fn description(&self) -> &str {
"Check for and install the latest OpenCrabs release. \
Automatically detects the install method (pre-built binary, \
cargo install, or source) and uses the right update strategy. \
Hot-restarts into the new version after installation."
}
fn input_schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"check_only": {
"type": "boolean",
"description": "If true, only check for updates without installing. Default: false."
}
},
"required": []
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::SystemModification]
}
fn requires_approval(&self) -> bool {
true
}
async fn execute(&self, input: Value, context: &ToolExecutionContext) -> Result<ToolResult> {
let check_only = input
.get("check_only")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let current_user_version: Option<i64> =
input.get("current_user_version").and_then(|v| v.as_i64());
let current_version = crate::VERSION;
let sid = context.session_id;
let install_method = InstallMethod::detect();
let _evolve_guard = if check_only {
None
} else if EVOLVE_IN_PROGRESS
.compare_exchange(
false,
true,
std::sync::atomic::Ordering::AcqRel,
std::sync::atomic::Ordering::Acquire,
)
.is_err()
{
tracing::warn!(
target: "evolve",
session_id = %sid,
"evolve: another evolve is already in progress — skipping this concurrent run"
);
return Ok(ToolResult::error(
"An update is already in progress; skipping this concurrent evolve run."
.to_string(),
));
} else {
Some(EvolveInProgressGuard)
};
if let Some(ref cb) = self.progress {
cb(
sid,
ProgressEvent::IntermediateText {
text: format!(
"Checking for updates (install: {})...",
install_method.description()
),
reasoning: None,
},
);
}
let client = reqwest::Client::new();
tracing::info!(
target: "evolve",
url = GITHUB_API,
current_version,
install_method = install_method.description(),
os = std::env::consts::OS,
arch = std::env::consts::ARCH,
session_id = %sid,
check_only,
"evolve: fetching releases/latest"
);
let resp = match client
.get(GITHUB_API)
.header("User-Agent", format!("opencrabs/{}", current_version))
.header("Accept", "application/vnd.github+json")
.send()
.await
{
Ok(r) => r,
Err(e) => {
tracing::warn!(
target: "evolve",
url = GITHUB_API,
error = %e,
session_id = %sid,
"evolve: network error reaching GitHub"
);
return Ok(ToolResult::error(format!(
"Failed to reach GitHub ({GITHUB_API}): {e}"
)));
}
};
let status = resp.status();
let ratelimit_remaining = resp
.headers()
.get("x-ratelimit-remaining")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let ratelimit_reset = resp
.headers()
.get("x-ratelimit-reset")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let release: Value = if status.is_success() {
match resp.json().await {
Ok(v) => v,
Err(e) => {
tracing::warn!(
target: "evolve",
url = GITHUB_API,
error = %e,
session_id = %sid,
"evolve: 200 response but JSON parse failed"
);
return Ok(ToolResult::error(format!(
"Failed to parse release info from {GITHUB_API}: {e}"
)));
}
}
} else {
let body = resp.text().await.unwrap_or_default();
let body_excerpt: String = body.chars().take(300).collect();
tracing::warn!(
target: "evolve",
url = GITHUB_API,
%status,
ratelimit_remaining = ratelimit_remaining.as_deref().unwrap_or("-"),
ratelimit_reset = ratelimit_reset.as_deref().unwrap_or("-"),
body_excerpt = %body_excerpt,
session_id = %sid,
"evolve: releases/latest returned non-2xx"
);
return Ok(ToolResult::error(diagnose_releases_latest_status(
status,
&body_excerpt,
ratelimit_remaining.as_deref(),
ratelimit_reset.as_deref(),
)));
};
let latest_tag = release["tag_name"].as_str().unwrap_or("unknown");
let latest_version = latest_tag.strip_prefix('v').unwrap_or(latest_tag);
if latest_version == current_version {
return Ok(ToolResult::success(format!(
"Already on the latest version (v{}).",
current_version
)));
}
if matches!(install_method, InstallMethod::PrebuiltBinary)
&& !has_platform_asset(&release, latest_tag)
{
let asset_count = release["assets"].as_array().map(|a| a.len()).unwrap_or(0);
return Ok(ToolResult::error(format!(
"v{} release exists but the binary for {}/{} is not available yet \
({} assets uploaded so far). The release may still be building — \
try again in a few minutes.",
latest_version,
std::env::consts::OS,
std::env::consts::ARCH,
asset_count
)));
}
if check_only {
return Ok(ToolResult::success(format!(
"Update available: v{} -> v{} (install method: {}). Run /evolve to install.",
current_version,
latest_version,
install_method.description()
)));
}
match install_method {
InstallMethod::Source(_) => {
return Ok(ToolResult::success(format!(
"Update available: v{} -> v{}. You're running from source — use /rebuild \
to pull and build the latest version, or `git checkout v{}` to switch.",
current_version, latest_version, latest_version
)));
}
InstallMethod::CargoInstall => {
return self
.evolve_via_cargo_install(sid, current_version, latest_version)
.await;
}
InstallMethod::PrebuiltBinary => {
return self
.evolve_via_binary_download(
sid,
&client,
&release,
current_version,
latest_tag,
latest_version,
current_user_version,
)
.await;
}
}
}
}
impl EvolveTool {
async fn evolve_via_cargo_install(
&self,
sid: uuid::Uuid,
current_version: &str,
latest_version: &str,
) -> Result<ToolResult> {
if let Some(ref cb) = self.progress {
cb(
sid,
ProgressEvent::IntermediateText {
text: format!(
"Updating via cargo install (v{} -> v{})...",
current_version, latest_version
),
reasoning: None,
},
);
}
tracing::info!(
target: "evolve",
current_version,
latest_version,
session_id = %sid,
"evolve: running `cargo install opencrabs --force`"
);
let output = tokio::process::Command::new("cargo")
.args(["install", "opencrabs", "--force"])
.stdin(std::process::Stdio::null())
.output()
.await
.map_err(|e| {
tracing::warn!(
target: "evolve",
error = %e,
session_id = %sid,
"evolve: failed to spawn `cargo` — is the Rust toolchain installed?"
);
super::error::ToolError::Execution(format!("Failed to spawn cargo: {e}"))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr_excerpt: String = stderr.chars().take(500).collect();
tracing::warn!(
target: "evolve",
exit_status = %output.status,
stderr_excerpt = %stderr_excerpt,
session_id = %sid,
"evolve: cargo install failed"
);
return Ok(ToolResult::error(format!(
"cargo install failed: {stderr_excerpt}"
)));
}
if let Some(ref cb) = self.progress {
cb(
sid,
ProgressEvent::RestartReady {
status: format!(
"Evolved via cargo install: v{} -> v{}.",
current_version, latest_version
),
binary_path: None,
},
);
}
Ok(ToolResult::success(format!(
"Evolved from v{} to v{} via cargo install.",
current_version, latest_version
)))
}
#[allow(clippy::too_many_arguments)]
async fn evolve_via_binary_download(
&self,
sid: uuid::Uuid,
client: &reqwest::Client,
release: &Value,
current_version: &str,
latest_tag: &str,
latest_version: &str,
current_user_version: Option<i64>,
) -> Result<ToolResult> {
let suffix = match platform_suffix() {
Some(s) => s,
None => {
return Ok(ToolResult::error(format!(
"Unsupported platform: {}/{}. Use /rebuild to build from source.",
std::env::consts::OS,
std::env::consts::ARCH
)));
}
};
let is_windows = std::env::consts::OS == "windows";
let ext = if is_windows { "zip" } else { "tar.gz" };
let expected_asset = format!("opencrabs-{}-{}.{}", latest_tag, suffix, ext);
let assets = release["assets"].as_array();
let download_url = assets
.and_then(|arr| {
arr.iter().find_map(|a| {
let name = a["name"].as_str()?;
if name == expected_asset {
a["browser_download_url"].as_str().map(String::from)
} else {
None
}
})
})
.or_else(|| {
let legacy_asset = format!("opencrabs-{}.{}", suffix, ext);
assets.and_then(|arr| {
arr.iter().find_map(|a| {
let name = a["name"].as_str()?;
if name == legacy_asset {
a["browser_download_url"].as_str().map(String::from)
} else {
None
}
})
})
});
let download_url = match download_url {
Some(url) => url,
None => {
return Ok(ToolResult::error(format!(
"No binary found for {} in v{}. Expected: {}. \
Available assets: {}. Use /rebuild to build from source.",
suffix,
latest_version,
expected_asset,
assets
.map(|arr| arr
.iter()
.filter_map(|a| a["name"].as_str())
.collect::<Vec<_>>()
.join(", "))
.unwrap_or_default()
)));
}
};
if let Some(ref cb) = self.progress {
cb(
sid,
ProgressEvent::IntermediateText {
text: format!("Downloading opencrabs v{}...", latest_version),
reasoning: None,
},
);
}
tracing::info!(
target: "evolve",
url = %download_url,
expected_asset = %expected_asset,
session_id = %sid,
"evolve: downloading release asset"
);
let archive_bytes = match client.get(&download_url).send().await {
Ok(resp) if resp.status().is_success() => {
let content_length = resp.content_length();
match resp.bytes().await {
Ok(b) if b.is_empty() => {
tracing::warn!(
target: "evolve",
url = %download_url,
content_length = ?content_length,
session_id = %sid,
"evolve: download returned empty body"
);
return Ok(ToolResult::error(format!(
"Download from {download_url} returned an empty file \
(content-length={content_length:?}). The release asset \
may still be uploading — try again in a few minutes."
)));
}
Ok(b) => b,
Err(e) => {
tracing::warn!(
target: "evolve",
url = %download_url,
error = %e,
session_id = %sid,
"evolve: download body read failed"
);
return Ok(ToolResult::error(format!(
"Download from {download_url} failed mid-stream: {e}"
)));
}
}
}
Ok(resp) => {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
let body_excerpt: String = body.chars().take(200).collect();
tracing::warn!(
target: "evolve",
url = %download_url,
%status,
body_excerpt = %body_excerpt,
session_id = %sid,
"evolve: download returned non-2xx"
);
return Ok(ToolResult::error(format!(
"Download from {download_url} failed with status {status}{}",
if body_excerpt.trim().is_empty() {
String::new()
} else {
format!(" — body: {body_excerpt}")
}
)));
}
Err(e) => {
tracing::warn!(
target: "evolve",
url = %download_url,
error = %e,
session_id = %sid,
"evolve: download request failed to send"
);
return Ok(ToolResult::error(format!(
"Download from {download_url} failed: {e}"
)));
}
};
tracing::info!(
target: "evolve",
asset = %expected_asset,
bytes = archive_bytes.len(),
session_id = %sid,
"evolve: download complete"
);
let bin_name = binary_name();
let binary_data = if is_windows {
extract_from_zip(&archive_bytes, bin_name)?
} else {
extract_from_tar_gz(&archive_bytes, bin_name)?
};
let exe_path = match crate::brain::self_update::running_binary_path() {
Ok(p) => p,
Err(e) => {
tracing::warn!(
target: "evolve",
error = %e,
session_id = %sid,
"evolve: current_exe() failed — cannot locate running binary"
);
return Ok(ToolResult::error(format!(
"Cannot locate current binary: {e}"
)));
}
};
let tmp_path = exe_path.with_extension(format!("evolve_tmp.{}", std::process::id()));
if let Err(e) = tokio::fs::write(&tmp_path, &binary_data).await {
tracing::warn!(
target: "evolve",
tmp_path = %tmp_path.display(),
error = %e,
session_id = %sid,
"evolve: failed to write temp binary"
);
return Ok(ToolResult::error(format!(
"Failed to write new binary to {}: {e}",
tmp_path.display()
)));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o755);
if let Err(e) = std::fs::set_permissions(&tmp_path, perms) {
tracing::warn!(
target: "evolve",
tmp_path = %tmp_path.display(),
error = %e,
session_id = %sid,
"evolve: failed to set 0o755 on temp binary"
);
let _ = std::fs::remove_file(&tmp_path);
return Ok(ToolResult::error(format!(
"Failed to set permissions on {}: {e}",
tmp_path.display()
)));
}
}
if let Some(ref cb) = self.progress {
cb(
sid,
ProgressEvent::IntermediateText {
text: "Verifying new binary...".into(),
reasoning: None,
},
);
}
if let Err(reason) = health_check_binary(&tmp_path).await {
tracing::warn!(
target: "evolve",
tmp_path = %tmp_path.display(),
%reason,
session_id = %sid,
"evolve: pre-swap health check failed, discarding new binary"
);
let _ = std::fs::remove_file(&tmp_path);
return Ok(ToolResult::error(format!(
"Health check failed ({reason}). Keeping current v{current_version}."
)));
}
if let Some(db_version) = current_user_version {
match get_binary_migration_count(&tmp_path).await {
Ok(new_migration_count) => {
let db_migration = db_version as usize;
if db_migration > new_migration_count {
tracing::warn!(
target: "evolve",
db_user_version = db_version,
new_binary_migration_count = new_migration_count,
session_id = %sid,
"evolve: database schema v{} is newer than new binary's migration \
count v{} — refusing to swap",
db_migration,
new_migration_count,
);
let _ = std::fs::remove_file(&tmp_path);
return Ok(ToolResult::error(format!(
"Database schema v{db_migration} is newer than v{latest_version}'s \
migration support (v{new_migration_count}). \
Keeping current v{current_version}. \
This usually means the release predates your database schema."
)));
}
}
Err(e) => {
tracing::warn!(
target: "evolve",
error = %e,
session_id = %sid,
"evolve: could not determine new binary's migration count, \
skipping compatibility check"
);
}
}
}
let backup_path = exe_path.with_extension("evolve_backup");
if let Err(e) = std::fs::copy(&exe_path, &backup_path) {
tracing::warn!(
target: "evolve",
exe_path = %exe_path.display(),
backup_path = %backup_path.display(),
error = %e,
session_id = %sid,
"evolve: backup copy failed — rollback will not be possible if swap goes bad"
);
}
if let Err(e) = std::fs::remove_file(&exe_path) {
tracing::debug!(
target: "evolve",
exe_path = %exe_path.display(),
error = %e,
session_id = %sid,
"evolve: pre-rename unlink failed (non-fatal; rename will report the real error if any)"
);
}
if let Err(e) = std::fs::rename(&tmp_path, &exe_path) {
tracing::warn!(
target: "evolve",
tmp_path = %tmp_path.display(),
exe_path = %exe_path.display(),
error = %e,
session_id = %sid,
"evolve: atomic rename of tmp -> exe failed"
);
let _ = std::fs::remove_file(&tmp_path);
return Ok(ToolResult::error(format!(
"Failed to replace binary at {}: {e}",
exe_path.display()
)));
}
if let Err(reason) = health_check_binary(&exe_path).await {
if backup_path.exists() {
if let Err(e) = std::fs::rename(&backup_path, &exe_path) {
tracing::error!(
target: "evolve",
exe_path = %exe_path.display(),
backup_path = %backup_path.display(),
post_swap_reason = %reason,
rollback_error = %e,
session_id = %sid,
"evolve: CRITICAL — post-swap health check failed AND rollback failed; \
binary at exe_path is broken and backup could not be restored. \
Manual recovery needed."
);
return Ok(ToolResult::error(format!(
"CRITICAL: New binary failed ({reason}) AND rollback failed: {e}. \
Manual recovery needed (backup is at {}).",
backup_path.display()
)));
}
tracing::error!(
target: "evolve",
exe_path = %exe_path.display(),
post_swap_reason = %reason,
session_id = %sid,
"evolve: post-swap health check failed, rolled back to previous version"
);
return Ok(ToolResult::error(format!(
"New binary failed post-swap ({reason}). Rolled back to v{current_version}."
)));
}
tracing::error!(
target: "evolve",
exe_path = %exe_path.display(),
post_swap_reason = %reason,
session_id = %sid,
"evolve: post-swap health check failed and no backup exists for rollback"
);
return Ok(ToolResult::error(format!(
"New binary failed post-swap ({reason}). No backup for rollback."
)));
}
let _ = std::fs::remove_file(&backup_path);
let rtk_bin_name = if is_windows { "rtk.exe" } else { "rtk" };
let rtk_result = if is_windows {
extract_from_zip(&archive_bytes, rtk_bin_name)
} else {
extract_from_tar_gz(&archive_bytes, rtk_bin_name)
};
match rtk_result {
Ok(rtk_data) => {
let rtk_path = exe_path
.parent()
.unwrap_or(std::path::Path::new("."))
.join(rtk_bin_name);
match tokio::fs::write(&rtk_path, &rtk_data).await {
Ok(()) => {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o755);
if let Err(e) = std::fs::set_permissions(&rtk_path, perms) {
tracing::warn!(
target: "evolve",
rtk_path = %rtk_path.display(),
error = %e,
"evolve: extracted RTK but failed to set executable permissions"
);
}
}
tracing::info!(
target: "evolve",
rtk_path = %rtk_path.display(),
bytes = rtk_data.len(),
session_id = %sid,
"evolve: extracted and installed bundled RTK binary"
);
}
Err(e) => {
tracing::warn!(
target: "evolve",
rtk_path = %rtk_path.display(),
error = %e,
"evolve: extracted RTK data but failed to write binary to disk"
);
}
}
}
Err(e) => {
tracing::debug!(
target: "evolve",
error = %e,
session_id = %sid,
"evolve: no RTK binary found in release archive (expected in newer releases)"
);
}
}
let mut restart_status = RestartStatus::NotSystemd;
let mut use_user_units = false;
if std::path::Path::new("/run/systemd/system").exists() {
let mut unit_count = count_matching_systemd_units(SYSTEMD_UNIT_PATTERN, false);
if unit_count == Some(0) {
let user_count = count_matching_systemd_units(SYSTEMD_UNIT_PATTERN, true);
match user_count {
Some(n) if n > 0 => {
use_user_units = true;
unit_count = Some(n);
tracing::info!(
target: "evolve",
pattern = SYSTEMD_UNIT_PATTERN,
user_units = n,
session_id = %sid,
"evolve: no system-level units found, using {n} user-level units — scheduling restart with --user"
);
}
_ => {
}
}
}
match unit_count {
Some(0) => {
tracing::warn!(
target: "evolve",
pattern = SYSTEMD_UNIT_PATTERN,
session_id = %sid,
"evolve: no systemd units matched the pattern (checked system and user level) — skipping scheduled restart"
);
restart_status = RestartStatus::NoUnitsMatched;
}
_ => {
if let Some(n) = unit_count {
tracing::info!(
target: "evolve",
pattern = SYSTEMD_UNIT_PATTERN,
matched_units = n,
use_user_units,
session_id = %sid,
"evolve: pre-flight found matching systemd units, scheduling restart (+3s)"
);
} else {
tracing::warn!(
target: "evolve",
pattern = SYSTEMD_UNIT_PATTERN,
session_id = %sid,
"evolve: could not determine matching unit count (systemctl spawn failed), \
scheduling restart anyway"
);
}
let pid = std::process::id();
let unit_name = format!("opencrabs-evolve-{pid}");
match build_systemd_cleanup_command(use_user_units).status() {
Ok(st) => tracing::info!(
target: "evolve",
glob = EVOLVE_UNIT_GLOB,
success = st.success(),
session_id = %sid,
"evolve: reset-failed swept stale evolve units"
),
Err(e) => tracing::warn!(
target: "evolve",
glob = EVOLVE_UNIT_GLOB,
error = %e,
session_id = %sid,
"evolve: could not sweep stale evolve units (reset-failed spawn failed) — \
they will linger until manually cleared, but the restart still proceeds"
),
}
match build_systemd_restart_command(pid, use_user_units).spawn() {
Ok(child) => {
tracing::info!(
target: "evolve",
unit = %unit_name,
systemd_run_pid = child.id(),
session_id = %sid,
"evolve: systemd-run spawned; daemon will restart in 3s"
);
restart_status = RestartStatus::Scheduled;
}
Err(e) => {
tracing::warn!(
target: "evolve",
unit = %unit_name,
error = %e,
session_id = %sid,
"evolve: failed to spawn systemd-run — daemon will NOT auto-restart, \
manual `systemctl restart opencrabs*.service` (or `systemctl --user restart` \
for user services) is required to load the new binary"
);
restart_status = RestartStatus::SpawnFailed(e.to_string());
}
}
}
}
}
if let Some(ref cb) = self.progress {
cb(
sid,
ProgressEvent::RestartReady {
status: format!(
"Evolved: v{} -> v{}. Restarting now.",
current_version, latest_version
),
binary_path: Some(exe_path.clone()),
},
);
}
Ok(ToolResult::success(
restart_status.user_message(current_version, latest_version),
))
}
}
#[derive(Debug)]
enum RestartStatus {
NotSystemd,
NoUnitsMatched,
Scheduled,
SpawnFailed(String),
}
impl RestartStatus {
fn user_message(&self, current: &str, latest: &str) -> String {
match self {
RestartStatus::Scheduled => {
format!("Evolved from v{current} to v{latest}.")
}
RestartStatus::NotSystemd => format!(
"Evolved from v{current} to v{latest}. Binary updated on disk; restart \
the process / relaunch to load the new version."
),
RestartStatus::NoUnitsMatched => format!(
"Evolved from v{current} to v{latest}. Binary updated on disk, but no \
systemd units matched `{SYSTEMD_UNIT_PATTERN}` at system or user level \
— your daemon (if any) was not restarted. Restart it manually with \
`systemctl --user restart {SYSTEMD_UNIT_PATTERN}` (if installed as a \
user service) or `systemctl restart <your-unit>` (if a system service), \
or relaunch if running standalone."
),
RestartStatus::SpawnFailed(err) => format!(
"Evolved from v{current} to v{latest}. Binary updated on disk, but \
scheduling the systemd restart failed ({err}). Restart your daemon \
manually with `systemctl --user restart {SYSTEMD_UNIT_PATTERN}` \
(if a user service) or `systemctl restart {SYSTEMD_UNIT_PATTERN}` \
(if a system service)."
),
}
}
}
fn extract_from_tar_gz(data: &[u8], file_name: &str) -> Result<Vec<u8>> {
use std::io::Read;
let decoder = flate2::read::GzDecoder::new(data);
let mut archive = tar::Archive::new(decoder);
for entry in archive
.entries()
.map_err(|e| super::error::ToolError::Execution(format!("Failed to read archive: {}", e)))?
{
let mut entry = entry.map_err(|e| {
super::error::ToolError::Execution(format!("Failed to read entry: {}", e))
})?;
let path = entry
.path()
.map_err(|e| {
super::error::ToolError::Execution(format!("Invalid path in archive: {}", e))
})?
.to_path_buf();
if path.file_name().and_then(|n| n.to_str()) == Some(file_name) {
let mut buf = Vec::new();
entry.read_to_end(&mut buf).map_err(|e| {
super::error::ToolError::Execution(format!("Failed to extract: {}", e))
})?;
return Ok(buf);
}
}
Err(super::error::ToolError::Execution(format!(
"'{}' not found in archive",
file_name
)))
}
fn extract_from_zip(data: &[u8], file_name: &str) -> Result<Vec<u8>> {
use std::io::Read;
let reader = std::io::Cursor::new(data);
let mut archive = zip::ZipArchive::new(reader)
.map_err(|e| super::error::ToolError::Execution(format!("Failed to read zip: {}", e)))?;
for i in 0..archive.len() {
let mut file = archive.by_index(i).map_err(|e| {
super::error::ToolError::Execution(format!("Failed to read zip entry: {}", e))
})?;
if file.name().ends_with(file_name) {
let mut buf = Vec::new();
file.read_to_end(&mut buf).map_err(|e| {
super::error::ToolError::Execution(format!("Failed to extract: {}", e))
})?;
return Ok(buf);
}
}
Err(super::error::ToolError::Execution(format!(
"'{}' not found in zip",
file_name
)))
}