use anyhow::Result;
use serde::{Deserialize, Serialize};
use super::config::{CliConfig, ResolvedConfig};
use super::leases::format_relative_time;
use super::select::{OnAmbiguous, resolve_lease_id};
use super::{OutputFormat, print_json, send_lease_request};
const SANDBOX_LEASE_PREFIX: &str = "sandbox-";
pub(crate) fn is_sandbox_lease(id: &str) -> bool {
id.starts_with(SANDBOX_LEASE_PREFIX)
}
#[derive(Serialize)]
struct ExtendRequest<'a> {
extend_ttl: &'a str,
}
#[derive(Deserialize)]
struct ExtendResponse {
#[serde(default)]
expires_at: Option<String>,
#[serde(default, rename = "expiresAt")]
expires_at_camel: Option<String>,
#[serde(default, alias = "runningExecutions")]
running_executions: Vec<RunningExecution>,
}
impl ExtendResponse {
fn expires_at(&self) -> Result<String> {
self.expires_at
.clone()
.or_else(|| self.expires_at_camel.clone())
.ok_or_else(|| anyhow::anyhow!("extend response has no expiry"))
}
}
#[derive(Deserialize, Serialize)]
struct RunningExecution {
id: String,
deadline: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ExtendOutput<'a> {
lease_id: &'a str,
expires_at: &'a str,
#[serde(skip_serializing_if = "<[_]>::is_empty")]
running_executions: &'a [RunningExecution],
}
pub(crate) async fn extend_lease(
config: &ResolvedConfig,
lease_id: &str,
by: &str,
) -> Result<String> {
extend_lease_response(config, lease_id, by)
.await?
.expires_at()
}
async fn extend_lease_response(
config: &ResolvedConfig,
lease_id: &str,
by: &str,
) -> Result<ExtendResponse> {
let body = serde_json::to_vec(&ExtendRequest { extend_ttl: by })?;
let response =
send_lease_request(config, reqwest::Method::PATCH, lease_id, Some(&body)).await?;
let status = response.status();
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
let parsed = serde_json::from_str::<serde_json::Value>(&text).ok();
let msg = parsed
.as_ref()
.and_then(|value| value["error"].as_str().map(str::to_string))
.unwrap_or(text.clone());
let reason = parsed
.as_ref()
.and_then(|value| value["reason"].as_str())
.map(|reason| format!(" [{reason}]"))
.unwrap_or_default();
anyhow::bail!("Failed to extend lease {lease_id} (HTTP {status}){reason}: {msg}");
}
Ok(response.json().await?)
}
pub async fn extend(
target: Option<&str>,
by: &str,
target_override: Option<&str>,
endpoint_override: Option<&str>,
output: OutputFormat,
) -> Result<()> {
let config = CliConfig::load()?;
let config = config.resolve(target_override, endpoint_override)?;
let lease_id = match target {
Some(target) if is_sandbox_lease(target) => target.to_string(),
target => resolve_lease_id(&config, target, output, OnAmbiguous::Reject).await?,
};
let extended = extend_lease_response(&config, &lease_id, by).await?;
let expires_at = extended.expires_at()?;
match output {
OutputFormat::Text => {
println!(
"Extended lease {lease_id} — expires {} ({})",
expires_at,
format_relative_time(&expires_at),
);
for execution in &extended.running_executions {
eprintln!(
"kobe: running execution {} still stops at {}; extending the lease does not move it",
execution.id,
super::sandbox::describe_deadline(&execution.deadline),
);
}
}
OutputFormat::Json => print_json(&ExtendOutput {
lease_id: &lease_id,
expires_at: &expires_at,
running_executions: &extended.running_executions,
})?,
}
Ok(())
}
#[cfg(test)]
mod tests {
#[test]
fn extend_response_accepts_either_or_both_expiry_spellings() {
for body in [
r#"{"expires_at":"2026-09-18T23:30:00Z"}"#,
r#"{"expiresAt":"2026-09-18T23:30:00Z"}"#,
r#"{"expires_at":"2026-09-18T23:30:00Z","expiresAt":"2026-09-18T23:30:00Z"}"#,
] {
let parsed: ExtendResponse = serde_json::from_str(body).unwrap();
assert_eq!(parsed.expires_at().unwrap(), "2026-09-18T23:30:00Z");
}
let empty: ExtendResponse = serde_json::from_str("{}").unwrap();
assert!(empty.expires_at().is_err());
}
use super::*;
#[test]
fn running_executions_are_read_when_present_and_empty_otherwise() {
let sandbox: ExtendResponse = serde_json::from_value(serde_json::json!({
"expiresAt": "2026-09-18T23:30:00Z",
"extensionsCount": 1,
"maxExtensions": 3,
"runningExecutions": [{ "id": "sbxe-1", "deadline": "2026-09-18T21:30:00Z" }]
}))
.unwrap();
assert_eq!(sandbox.running_executions.len(), 1);
assert_eq!(sandbox.running_executions[0].id, "sbxe-1");
let cluster: ExtendResponse =
serde_json::from_value(serde_json::json!({ "expires_at": "2026-09-18T23:30:00Z" }))
.unwrap();
assert!(cluster.running_executions.is_empty());
}
}