Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/api/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ use utoipa::OpenApi;
crate::routes::agents::start_instance,
crate::routes::agents::stop_instance,
crate::routes::agents::restart_instance,
crate::routes::agents::upgrade_instance,
crate::routes::admin::admin_create_backup,
crate::routes::admin::admin_list_backups,
crate::routes::admin::admin_get_backup,
Expand Down
69 changes: 69 additions & 0 deletions crates/api/src/routes/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,7 @@ pub fn create_agent_router() -> Router<AppState> {
.route("/instances/{id}/start", post(start_instance))
.route("/instances/{id}/stop", post(stop_instance))
.route("/instances/{id}/restart", post(restart_instance))
.route("/instances/{id}/upgrade", post(upgrade_instance))
.route("/instances/{id}", delete(delete_instance))
}

Expand Down Expand Up @@ -982,3 +983,71 @@ pub async fn restart_instance(
.body(axum::body::Body::empty())
.map_err(|_| ApiError::internal_server_error("Failed to construct response"))
}

/// Upgrade an agent instance to the latest image
#[utoipa::path(
post,
path = "/v1/agents/instances/{id}/upgrade",
tag = "Agents",
params(
("id" = String, Path, description = "Instance ID")
),
responses(
(status = 200, description = "Instance upgraded"),
(status = 401, description = "Unauthorized", body = crate::error::ApiErrorResponse),
(status = 403, description = "Forbidden - not your instance", body = crate::error::ApiErrorResponse),
(status = 404, description = "Instance not found", body = crate::error::ApiErrorResponse),
(status = 500, description = "Internal server error", body = crate::error::ApiErrorResponse)
),
security(("session_token" = []))
)]
pub async fn upgrade_instance(
State(app_state): State<AppState>,
Extension(user): Extension<AuthenticatedUser>,
Path(instance_id): Path<String>,
) -> Result<Response, ApiError> {
let instance_uuid = Uuid::parse_str(&instance_id)
.map_err(|_| ApiError::bad_request("Invalid instance ID format"))?;

tracing::debug!(
"Upgrading instance: instance_id={}, user_id={}",
instance_uuid,
user.user_id
);

let instance = app_state
.agent_repository
.get_instance(instance_uuid)
.await
.map_err(|e| {
tracing::error!(
"Failed to fetch instance: instance_id={}, error={}",
instance_uuid,
e
);
ApiError::internal_server_error("Failed to fetch instance")
})?
.ok_or_else(|| ApiError::not_found("Instance not found"))?;

if instance.user_id != user.user_id {
return Err(ApiError::forbidden("This instance does not belong to you"));
}

app_state
.agent_service
.upgrade_instance(instance_uuid, user.user_id)
.await
.map_err(|e| {
tracing::error!(
"Failed to upgrade instance: instance_id={}, error={}",
instance_uuid,
e
);
ApiError::internal_server_error("Failed to upgrade instance")
})?;

Response::builder()
.status(StatusCode::OK)
.body(axum::body::Body::empty())
.map_err(|_| ApiError::internal_server_error("Failed to construct response"))
}
4 changes: 4 additions & 0 deletions crates/services/src/agent/ports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,10 @@ pub trait AgentService: Send + Sync {

async fn restart_instance(&self, instance_id: Uuid, user_id: UserId) -> anyhow::Result<()>;

/// Upgrade instance to the latest image for its service type.
/// Fetches current images from the owning compose-api, then restarts with the latest digest.
async fn upgrade_instance(&self, instance_id: Uuid, user_id: UserId) -> anyhow::Result<()>;

async fn stop_instance(&self, instance_id: Uuid, user_id: UserId) -> anyhow::Result<()>;

async fn start_instance(&self, instance_id: Uuid, user_id: UserId) -> anyhow::Result<()>;
Expand Down
96 changes: 96 additions & 0 deletions crates/services/src/agent/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1259,6 +1259,102 @@
Ok(())
}

async fn upgrade_instance(&self, instance_id: Uuid, user_id: UserId) -> anyhow::Result<()> {
tracing::info!(
"Upgrading instance: instance_id={}, user_id={}",
instance_id,
user_id
);

let instance = self
.repository
.get_instance(instance_id)
.await?
.ok_or_else(|| anyhow!("Instance not found"))?;

if instance.user_id != user_id {
return Err(anyhow!("Access denied"));
}

let manager = self.resolve_manager(&instance);

// Fetch latest images from compose-api
let version_url = format!("{}/version", manager.url);
let version_resp = self
.http_client
.get(&version_url)
.bearer_auth(&manager.token)
.send()
.await
.map_err(|e| anyhow!("Failed to fetch compose-api version: {}", e))?;

if !version_resp.status().is_success() {
return Err(anyhow!(
"Failed to fetch compose-api version: status={}",
version_resp.status()
));
}

#[derive(serde::Deserialize)]
struct VersionResponse {
images: std::collections::HashMap<String, String>,
}

let version: VersionResponse = version_resp
.json()
.await
.map_err(|e| anyhow!("Failed to parse compose-api version response: {}", e))?;

// Map service_type to image key in the version response
let service_type = instance.service_type.as_deref().unwrap_or("openclaw");
let image_key = match service_type {
"ironclaw" => "ironclaw",
_ => "worker",

Check warning on line 1312 in crates/services/src/agent/service.rs

View workflow job for this annotation

GitHub Actions / Test Suite

Diff in /home/runner/work/chat-api/chat-api/crates/services/src/agent/service.rs
};

let image = version
.images
.get(image_key)
.ok_or_else(|| anyhow!("No image found for service type '{}' (key '{}')", service_type, image_key))?;

// Restart with the latest image
let encoded_name = urlencoding::encode(&instance.name);
let restart_url = format!("{}/instances/{}/restart", manager.url, encoded_name);

#[derive(serde::Serialize)]
struct RestartBody {
image: String,
}

let response = self
.http_client
.post(&restart_url)
.bearer_auth(&manager.token)
.json(&RestartBody {
image: image.clone(),
})
.send()
.await
.map_err(|e| anyhow!("Failed to call Agent API restart: {}", e))?;

if !response.status().is_success() {
return Err(anyhow!(
"Agent API upgrade-restart failed with status {}: instance_id={}",
response.status(),
instance_id
));
}

tracing::info!(
"Instance upgraded successfully: instance_id={}, name={}, image={}",
instance_id,
instance.name,
image_key
);

Ok(())
}

async fn stop_instance(&self, instance_id: Uuid, user_id: UserId) -> anyhow::Result<()> {
tracing::info!(
"Stopping instance: instance_id={}, user_id={}",
Expand Down
Loading