use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionRequest {
pub id: Uuid,
pub command: Command,
#[serde(default)]
pub env: HashMap<String, String>,
pub working_dir: Option<PathBuf>,
pub timeout_ms: Option<u64>,
pub output_log_path: Option<PathBuf>,
#[serde(default)]
pub metadata: ExecutionMetadata,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionPlan {
pub id: Uuid,
pub description: String,
pub strategy: ExecutionStrategy,
pub commands: Vec<ExecutionRequest>,
#[serde(default)]
pub metadata: ExecutionMetadata,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Command {
Script {
path: PathBuf,
interpreter: Option<String>,
},
Exec { program: String, args: Vec<String> },
Shell {
command: String,
#[serde(default = "default_shell")]
shell: String,
},
AwsCli {
service: String,
operation: String,
#[serde(default)]
args: Vec<String>,
profile: Option<String>,
region: Option<String>,
},
}
fn default_shell() -> String {
if cfg!(target_os = "windows") {
"powershell".to_string()
} else {
"bash".to_string()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ExecutionStrategy {
Serial {
#[serde(default = "default_stop_on_error")]
stop_on_error: bool,
},
Parallel { max_concurrency: Option<usize> },
DependencyGraph {
dependencies: HashMap<usize, Vec<usize>>,
},
}
fn default_stop_on_error() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionResult {
pub id: Uuid,
pub status: ExecutionStatus,
pub success: bool,
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
pub duration: Duration,
pub started_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
pub error: Option<String>,
pub stdout_overflow_file: Option<PathBuf>,
pub stderr_overflow_file: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanExecutionResult {
pub plan_id: Uuid,
pub status: ExecutionStatus,
pub results: Vec<ExecutionResult>,
pub total_duration: Duration,
pub stats: ExecutionStats,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionStatus {
Pending,
Running,
Completed,
Failed,
Cancelled,
Timeout,
}
impl ExecutionStatus {
#[must_use]
pub fn is_terminal(&self) -> bool {
matches!(
self,
ExecutionStatus::Completed
| ExecutionStatus::Failed
| ExecutionStatus::Cancelled
| ExecutionStatus::Timeout
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionStats {
pub total: usize,
pub completed: usize,
pub failed: usize,
pub cancelled: usize,
pub timeout: usize,
}
impl ExecutionStats {
#[must_use]
pub fn new(total: usize) -> Self {
Self {
total,
completed: 0,
failed: 0,
cancelled: 0,
timeout: 0,
}
}
pub fn update(&mut self, status: ExecutionStatus) {
match status {
ExecutionStatus::Completed => self.completed += 1,
ExecutionStatus::Failed => self.failed += 1,
ExecutionStatus::Cancelled => self.cancelled += 1,
ExecutionStatus::Timeout => self.timeout += 1,
_ => {}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ExecutionMetadata {
pub source: Option<String>,
pub conversation_id: Option<Uuid>,
#[serde(default)]
pub tags: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionSummary {
pub id: Uuid,
pub status: ExecutionStatus,
pub started_at: DateTime<Utc>,
pub duration: Option<Duration>,
}
#[derive(Debug, Clone)]
pub struct ExecutionState {
pub id: Uuid,
pub request: ExecutionRequest,
pub status: ExecutionStatus,
pub started_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
pub stdout: String,
pub stderr: String,
pub exit_code: Option<i32>,
pub error: Option<String>,
pub stdout_overflow_file: Option<PathBuf>,
pub stderr_overflow_file: Option<PathBuf>,
}
impl ExecutionState {
#[must_use]
pub fn new(request: ExecutionRequest) -> Self {
Self {
id: request.id,
request,
status: ExecutionStatus::Pending,
started_at: Utc::now(),
completed_at: None,
stdout: String::new(),
stderr: String::new(),
exit_code: None,
error: None,
stdout_overflow_file: None,
stderr_overflow_file: None,
}
}
#[must_use]
pub fn to_result(&self) -> ExecutionResult {
let duration = if let Some(completed) = self.completed_at {
(completed - self.started_at)
.to_std()
.unwrap_or(Duration::from_secs(0))
} else {
Duration::from_secs(0)
};
ExecutionResult {
id: self.id,
status: self.status,
success: self.exit_code == Some(0),
exit_code: self.exit_code.unwrap_or(-1),
stdout: self.stdout.clone(),
stderr: self.stderr.clone(),
duration,
started_at: self.started_at,
completed_at: self.completed_at,
error: self.error.clone(),
stdout_overflow_file: self.stdout_overflow_file.clone(),
stderr_overflow_file: self.stderr_overflow_file.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_execution_status_terminal() {
assert!(!ExecutionStatus::Pending.is_terminal());
assert!(!ExecutionStatus::Running.is_terminal());
assert!(ExecutionStatus::Completed.is_terminal());
assert!(ExecutionStatus::Failed.is_terminal());
assert!(ExecutionStatus::Cancelled.is_terminal());
assert!(ExecutionStatus::Timeout.is_terminal());
}
#[test]
fn test_execution_stats_update() {
let mut stats = ExecutionStats::new(5);
assert_eq!(stats.total, 5);
assert_eq!(stats.completed, 0);
stats.update(ExecutionStatus::Completed);
assert_eq!(stats.completed, 1);
stats.update(ExecutionStatus::Failed);
assert_eq!(stats.failed, 1);
stats.update(ExecutionStatus::Timeout);
assert_eq!(stats.timeout, 1);
}
#[test]
fn test_command_serialization() {
let cmd = Command::Shell {
command: "echo hello".to_string(),
shell: "bash".to_string(),
};
let json = serde_json::to_string(&cmd).unwrap();
assert!(json.contains("shell"));
assert!(json.contains("echo hello"));
let deserialized: Command = serde_json::from_str(&json).unwrap();
match deserialized {
Command::Shell { command, shell } => {
assert_eq!(command, "echo hello");
assert_eq!(shell, "bash");
}
_ => panic!("Wrong variant"),
}
}
#[test]
fn test_execution_request_default_fields() {
let request = ExecutionRequest {
id: Uuid::new_v4(),
command: Command::Shell {
command: "ls".to_string(),
shell: "bash".to_string(),
},
env: HashMap::new(),
working_dir: None,
timeout_ms: None,
output_log_path: None,
metadata: ExecutionMetadata::default(),
};
assert!(request.env.is_empty());
assert!(request.working_dir.is_none());
assert!(request.timeout_ms.is_none());
assert!(request.metadata.source.is_none());
}
#[test]
fn test_execution_state_to_result() {
let request = ExecutionRequest {
id: Uuid::new_v4(),
command: Command::Shell {
command: "echo test".to_string(),
shell: "bash".to_string(),
},
env: HashMap::new(),
working_dir: None,
timeout_ms: None,
output_log_path: None,
metadata: ExecutionMetadata::default(),
};
let mut state = ExecutionState::new(request);
state.status = ExecutionStatus::Completed;
state.exit_code = Some(0);
state.stdout = "test output".to_string();
state.completed_at = Some(Utc::now());
let result = state.to_result();
assert_eq!(result.status, ExecutionStatus::Completed);
assert!(result.success);
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "test output");
}
#[test]
fn test_default_shell() {
let shell = default_shell();
if cfg!(target_os = "windows") {
assert_eq!(shell, "powershell");
} else {
assert_eq!(shell, "bash");
}
}
#[test]
fn test_execution_metadata_default() {
let metadata = ExecutionMetadata::default();
assert!(metadata.source.is_none());
assert!(metadata.conversation_id.is_none());
assert!(metadata.tags.is_empty());
}
#[test]
fn test_execution_strategy_serialization() {
let strategy = ExecutionStrategy::Serial {
stop_on_error: true,
};
let json = serde_json::to_string(&strategy).unwrap();
assert!(json.contains("serial"));
assert!(json.contains("stop_on_error"));
}
}