use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub type StepId = Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[non_exhaustive]
pub enum TriggerMode {
#[default]
All,
Any,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[non_exhaustive]
pub enum BackoffStrategy {
#[default]
Fixed,
Linear,
Exponential,
}
impl BackoffStrategy {
#[must_use]
#[inline]
pub fn delay_ms(self, base_delay_ms: u64, attempt: u32) -> u64 {
match self {
Self::Fixed => base_delay_ms,
Self::Linear => base_delay_ms.saturating_mul(u64::from(attempt)),
Self::Exponential => base_delay_ms.saturating_mul(
1u64.checked_shl(attempt.saturating_sub(1))
.unwrap_or(u64::MAX),
),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum StepStatus {
Pending,
Running,
Completed,
Failed,
Skipped,
RolledBack,
}
impl std::fmt::Display for StepStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Pending => write!(f, "pending"),
Self::Running => write!(f, "running"),
Self::Completed => write!(f, "completed"),
Self::Failed => write!(f, "failed"),
Self::Skipped => write!(f, "skipped"),
Self::RolledBack => write!(f, "rolled_back"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepDef {
pub id: StepId,
pub name: String,
pub description: String,
pub timeout_ms: u64,
pub max_retries: u32,
pub retry_delay_ms: u64,
#[serde(default)]
pub backoff: BackoffStrategy,
pub rollbackable: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub step_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub condition: Option<String>,
pub depends_on: Vec<StepId>,
#[serde(default)]
pub trigger_mode: TriggerMode,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sub_steps: Vec<StepDef>,
#[cfg(feature = "hardware")]
#[serde(default)]
pub hardware: ai_hwaccel::AcceleratorRequirement,
}
impl StepDef {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
Self {
id: Uuid::new_v4(),
name: name.into(),
description: String::new(),
timeout_ms: 30_000,
max_retries: 0,
retry_delay_ms: 1_000,
backoff: BackoffStrategy::Fixed,
rollbackable: false,
step_type: None,
config: None,
condition: None,
depends_on: Vec::new(),
trigger_mode: TriggerMode::All,
sub_steps: Vec::new(),
#[cfg(feature = "hardware")]
hardware: ai_hwaccel::AcceleratorRequirement::None,
}
}
#[must_use]
pub fn with_timeout(mut self, ms: u64) -> Self {
self.timeout_ms = ms;
self
}
#[must_use]
pub fn with_retries(mut self, max: u32, delay_ms: u64) -> Self {
self.max_retries = max;
self.retry_delay_ms = delay_ms;
self
}
#[must_use]
pub fn with_backoff(mut self, strategy: BackoffStrategy) -> Self {
self.backoff = strategy;
self
}
#[must_use]
pub fn with_rollback(mut self) -> Self {
self.rollbackable = true;
self
}
#[must_use]
pub fn depends_on(mut self, step_id: StepId) -> Self {
self.depends_on.push(step_id);
self
}
#[must_use]
pub fn with_step_type(mut self, step_type: impl Into<String>) -> Self {
self.step_type = Some(step_type.into());
self
}
#[must_use]
pub fn with_config(mut self, config: serde_json::Value) -> Self {
self.config = Some(config);
self
}
#[must_use]
pub fn with_condition(mut self, expr: impl Into<String>) -> Self {
self.condition = Some(expr.into());
self
}
#[must_use]
pub fn with_trigger_mode(mut self, mode: TriggerMode) -> Self {
self.trigger_mode = mode;
self
}
#[must_use]
pub fn with_sub_step(mut self, step: StepDef) -> Self {
self.sub_steps.push(step);
self
}
#[cfg(feature = "hardware")]
#[must_use]
pub fn with_hardware(mut self, req: ai_hwaccel::AcceleratorRequirement) -> Self {
self.hardware = req;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepResult {
pub step_id: StepId,
pub status: StepStatus,
pub output: serde_json::Value,
pub duration_ms: u64,
pub attempts: u32,
pub error: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn step_builder() {
let step = StepDef::new("deploy")
.with_timeout(60_000)
.with_retries(3, 5_000)
.with_rollback();
assert_eq!(step.name, "deploy");
assert_eq!(step.timeout_ms, 60_000);
assert_eq!(step.max_retries, 3);
assert!(step.rollbackable);
}
#[test]
fn step_dependencies() {
let a = StepDef::new("build");
let b = StepDef::new("test").depends_on(a.id);
assert_eq!(b.depends_on.len(), 1);
assert_eq!(b.depends_on[0], a.id);
}
#[test]
fn status_display() {
assert_eq!(StepStatus::Completed.to_string(), "completed");
assert_eq!(StepStatus::RolledBack.to_string(), "rolled_back");
}
#[test]
fn serde_roundtrip() {
let step = StepDef::new("test");
let json = serde_json::to_string(&step).unwrap();
let back: StepDef = serde_json::from_str(&json).unwrap();
assert_eq!(back.name, "test");
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn serde_roundtrip_any(
name in "[a-z][a-z0-9_-]{0,30}",
timeout in 1u64..600_000,
retries in 0u32..10,
delay in 0u64..60_000,
) {
let step = StepDef::new(name.clone())
.with_timeout(timeout)
.with_retries(retries, delay);
let json = serde_json::to_string(&step).unwrap();
let back: StepDef = serde_json::from_str(&json).unwrap();
prop_assert_eq!(&back.name, &name);
prop_assert_eq!(back.timeout_ms, timeout);
prop_assert_eq!(back.max_retries, retries);
prop_assert_eq!(back.retry_delay_ms, delay);
}
#[test]
fn builder_preserves_id(
name in "[a-z][a-z0-9_-]{0,30}",
) {
let step = StepDef::new(name);
let id = step.id;
let step = step.with_timeout(5000).with_retries(2, 1000).with_rollback();
prop_assert_eq!(step.id, id);
}
}
}