use everruns_core::UpsertLeasedResource;
use everruns_core::tools::ToolExecutionResult;
use everruns_core::traits::ToolContext;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tracing::{error, warn};
use crate::SPRITES_SECRET_PREFIX;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpriteInfo {
pub name: String,
#[serde(default)]
pub status: String,
#[serde(default)]
pub url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecResult {
#[serde(default)]
pub stdout: String,
#[serde(default)]
pub stderr: String,
#[serde(default)]
pub exit_code: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckpointInfo {
pub id: String,
#[serde(default, alias = "create_time")]
pub created_at: Option<String>,
#[serde(default)]
pub comment: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpriteState {
pub sprite_name: String,
pub workspace_path: String,
pub started_at: String,
#[serde(default)]
pub service_url: Option<String>,
}
pub const SPRITES_LEASE_DURATION_SECONDS: u32 = 30 * 60;
pub async fn get_api_token(context: &ToolContext) -> Result<String, ToolExecutionResult> {
if let Some(resolver) = context.connection_resolver.as_ref() {
match resolver
.get_connection_token(context.session_id, "sprites")
.await
{
Ok(Some(token)) => return Ok(token),
Ok(None) => {} Err(e) => {
error!("Failed to resolve Sprites user connection: {e}");
}
}
}
Err(ToolExecutionResult::connection_required("sprites"))
}
pub async fn get_sprite_state(
context: &ToolContext,
sprite_name: &str,
) -> Result<SpriteState, ToolExecutionResult> {
validate_sprite_name(sprite_name)?;
let storage = context
.storage_store
.as_ref()
.ok_or_else(|| ToolExecutionResult::tool_error("Storage not available in this context"))?;
let secret_name = format!("{SPRITES_SECRET_PREFIX}{sprite_name}");
let json_str = storage
.get_secret(context.session_id, &secret_name)
.await
.map_err(|e| {
error!("Failed to read sprite state: {e}");
ToolExecutionResult::internal_error_msg(format!("Failed to read sprite state: {e}"))
})?
.ok_or_else(|| {
ToolExecutionResult::tool_error(format!(
"Sprite '{sprite_name}' not found. Create one first with sprites_create_sprite."
))
})?;
serde_json::from_str(&json_str).map_err(|e| {
error!("Corrupt sprite state for {sprite_name}: {e}");
ToolExecutionResult::internal_error_msg(format!("Corrupt sprite state: {e}"))
})
}
pub async fn save_sprite_state(
context: &ToolContext,
state: &SpriteState,
) -> Result<(), ToolExecutionResult> {
validate_sprite_name(&state.sprite_name)?;
let storage = context
.storage_store
.as_ref()
.ok_or_else(|| ToolExecutionResult::tool_error("Storage not available in this context"))?;
let secret_name = format!("{SPRITES_SECRET_PREFIX}{}", state.sprite_name);
let json_str = serde_json::to_string(state).map_err(|e| {
ToolExecutionResult::internal_error_msg(format!("Failed to serialize sprite state: {e}"))
})?;
storage
.set_secret(context.session_id, &secret_name, &json_str)
.await
.map_err(|e| {
error!("Failed to save sprite state: {e}");
ToolExecutionResult::internal_error_msg(format!("Failed to save sprite state: {e}"))
})
}
pub async fn delete_sprite_state(
context: &ToolContext,
sprite_name: &str,
) -> Result<(), ToolExecutionResult> {
validate_sprite_name(sprite_name)?;
let storage = context
.storage_store
.as_ref()
.ok_or_else(|| ToolExecutionResult::tool_error("Storage not available in this context"))?;
let secret_name = format!("{SPRITES_SECRET_PREFIX}{sprite_name}");
storage
.delete_secret(context.session_id, &secret_name)
.await
.map_err(|e| {
error!("Failed to delete sprite state: {e}");
ToolExecutionResult::internal_error_msg(format!("Failed to delete sprite state: {e}"))
})?;
Ok(())
}
pub async fn list_sprite_states(
context: &ToolContext,
) -> Result<Vec<SpriteState>, ToolExecutionResult> {
let storage = context
.storage_store
.as_ref()
.ok_or_else(|| ToolExecutionResult::tool_error("Storage not available in this context"))?;
let secrets = storage
.list_secrets(context.session_id)
.await
.map_err(|e| {
error!("Failed to list secrets: {e}");
ToolExecutionResult::internal_error_msg(format!("Failed to list secrets: {e}"))
})?;
let mut states = Vec::new();
for secret_info in secrets {
if let Some(sprite_name) = secret_info.name.strip_prefix(SPRITES_SECRET_PREFIX) {
match get_sprite_state(context, sprite_name).await {
Ok(state) => states.push(state),
Err(_) => {
warn!("Skipping corrupt sprite state: {}", sprite_name);
}
}
}
}
Ok(states)
}
pub async fn touch_sprite_lease(
context: &ToolContext,
state: &SpriteState,
display_name: Option<String>,
) -> Result<(), ToolExecutionResult> {
let Some(store) = context.leased_resource_store.as_ref() else {
return Ok(());
};
let owner_user_id = if let Some(resolver) = context.connection_resolver.as_ref() {
resolver
.get_connection_user(context.session_id, "sprites")
.await
.ok()
.flatten()
} else {
None
};
store
.upsert_resource(UpsertLeasedResource {
session_id: context.session_id,
provider: "sprites".to_string(),
resource_type: "sprite".to_string(),
external_id: state.sprite_name.clone(),
display_name,
owner_user_id,
lease_duration_seconds: SPRITES_LEASE_DURATION_SECONDS,
metadata: json!({
"workspace_path": state.workspace_path,
"started_at": state.started_at,
"service_url": state.service_url,
}),
})
.await
.map_err(|e| {
error!("Failed to upsert Sprites lease: {e}");
ToolExecutionResult::internal_error_msg(format!("Failed to update Sprites lease: {e}"))
})?;
Ok(())
}
pub async fn release_sprite_lease(
context: &ToolContext,
sprite_name: &str,
) -> Result<(), ToolExecutionResult> {
let Some(store) = context.leased_resource_store.as_ref() else {
return Ok(());
};
store
.release_resource(context.session_id, "sprites", "sprite", sprite_name)
.await
.map_err(|e| {
error!("Failed to release Sprites lease: {e}");
ToolExecutionResult::internal_error_msg(format!("Failed to release Sprites lease: {e}"))
})?;
Ok(())
}
pub fn required_str<'a>(args: &'a Value, name: &str) -> Result<&'a str, ToolExecutionResult> {
args.get(name).and_then(|v| v.as_str()).ok_or_else(|| {
ToolExecutionResult::tool_error(format!("Missing required parameter: {name}"))
})
}
pub fn validate_sprite_name(name: &str) -> Result<(), ToolExecutionResult> {
if name.is_empty() || name.len() > 63 {
return Err(ToolExecutionResult::tool_error(
"Sprite name must be 1-63 characters",
));
}
if !name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
{
return Err(ToolExecutionResult::tool_error(
"Sprite name must contain only lowercase letters, digits, and hyphens",
));
}
if name.starts_with('-') || name.ends_with('-') {
return Err(ToolExecutionResult::tool_error(
"Sprite name must not start or end with a hyphen",
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sprite_state_roundtrip() {
let state = SpriteState {
sprite_name: "my-sprite".to_string(),
workspace_path: "/home/sprite".to_string(),
started_at: "2026-03-23T10:00:00Z".to_string(),
service_url: Some("https://my-sprite.fly.dev".to_string()),
};
let json = serde_json::to_string(&state).unwrap();
let deserialized: SpriteState = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.sprite_name, "my-sprite");
assert_eq!(deserialized.workspace_path, "/home/sprite");
assert_eq!(
deserialized.service_url,
Some("https://my-sprite.fly.dev".to_string())
);
}
#[test]
fn test_sprite_state_without_url() {
let state = SpriteState {
sprite_name: "headless".to_string(),
workspace_path: "/home/sprite".to_string(),
started_at: "2026-03-23T10:00:00Z".to_string(),
service_url: None,
};
let json = serde_json::to_string(&state).unwrap();
let deserialized: SpriteState = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.service_url, None);
}
#[test]
fn test_required_str_present() {
let args = serde_json::json!({"name": "test_value"});
let result = required_str(&args, "name");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "test_value");
}
#[test]
fn test_required_str_missing() {
let args = serde_json::json!({"other": "value"});
let result = required_str(&args, "name");
assert!(result.is_err());
}
#[test]
fn test_required_str_null_value() {
let args = serde_json::json!({"name": null});
let result = required_str(&args, "name");
assert!(result.is_err());
}
#[test]
fn test_required_str_non_string_value() {
let args = serde_json::json!({"name": 42});
let result = required_str(&args, "name");
assert!(result.is_err());
}
#[test]
fn test_sprite_info_deserialization() {
let json =
r#"{"name": "my-sprite", "status": "running", "url": "https://my-sprite.fly.dev"}"#;
let info: SpriteInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.name, "my-sprite");
assert_eq!(info.status, "running");
assert_eq!(info.url, Some("https://my-sprite.fly.dev".to_string()));
}
#[test]
fn test_sprite_info_without_url() {
let json = r#"{"name": "headless", "status": "stopped"}"#;
let info: SpriteInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.url, None);
}
#[test]
fn test_exec_result_deserialization() {
let json = r#"{"stdout": "hello\n", "stderr": "", "exit_code": 0}"#;
let result: ExecResult = serde_json::from_str(json).unwrap();
assert_eq!(result.stdout, "hello\n");
assert_eq!(result.stderr, "");
assert_eq!(result.exit_code, 0);
}
#[test]
fn test_exec_result_defaults() {
let json = r#"{}"#;
let result: ExecResult = serde_json::from_str(json).unwrap();
assert_eq!(result.stdout, "");
assert_eq!(result.stderr, "");
assert_eq!(result.exit_code, 0);
}
#[test]
fn test_checkpoint_info_deserialization() {
let json = r#"{"id": "cp_abc", "created_at": "2026-03-23T10:00:00Z"}"#;
let info: CheckpointInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.id, "cp_abc");
assert_eq!(info.created_at, Some("2026-03-23T10:00:00Z".to_string()));
}
#[test]
fn test_checkpoint_info_without_timestamp() {
let json = r#"{"id": "cp_xyz"}"#;
let info: CheckpointInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.id, "cp_xyz");
assert_eq!(info.created_at, None);
}
#[test]
fn test_sprite_state_json_has_all_fields() {
let state = SpriteState {
sprite_name: "sp_x".to_string(),
workspace_path: "/home/sprite".to_string(),
started_at: "2026-01-01T00:00:00Z".to_string(),
service_url: None,
};
let val: serde_json::Value = serde_json::to_value(&state).unwrap();
assert!(val.get("sprite_name").is_some());
assert!(val.get("workspace_path").is_some());
assert!(val.get("started_at").is_some());
}
#[test]
fn test_validate_sprite_name_valid() {
assert!(validate_sprite_name("my-sprite").is_ok());
assert!(validate_sprite_name("test123").is_ok());
assert!(validate_sprite_name("a").is_ok());
assert!(validate_sprite_name("a-b-c").is_ok());
}
#[test]
fn test_validate_sprite_name_empty() {
assert!(validate_sprite_name("").is_err());
}
#[test]
fn test_validate_sprite_name_too_long() {
let long_name = "a".repeat(64);
assert!(validate_sprite_name(&long_name).is_err());
}
#[test]
fn test_validate_sprite_name_invalid_chars() {
assert!(validate_sprite_name("my sprite").is_err());
assert!(validate_sprite_name("my/sprite").is_err());
assert!(validate_sprite_name("MY-SPRITE").is_err());
assert!(validate_sprite_name("../../admin").is_err());
assert!(validate_sprite_name("name?extra=1").is_err());
}
#[test]
fn test_validate_sprite_name_leading_trailing_hyphen() {
assert!(validate_sprite_name("-bad").is_err());
assert!(validate_sprite_name("bad-").is_err());
}
}