use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::approval::{ApprovalRequest, ApprovalResult, ApprovalStatus};
use super::policy::ApprovalPolicy;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowConfig {
pub name: String,
pub timeout_secs: u64,
pub required_approvals: usize,
pub allow_modifications: bool,
pub escalation: Option<EscalationConfig>,
pub timeout_action: TimeoutAction,
}
impl Default for WorkflowConfig {
fn default() -> Self {
Self {
name: "default".to_string(),
timeout_secs: 300,
required_approvals: 1,
allow_modifications: true,
escalation: None,
timeout_action: TimeoutAction::Reject,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EscalationConfig {
pub escalate_after_secs: u64,
pub escalate_to: Vec<String>,
pub max_levels: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TimeoutAction {
Reject,
Approve,
Escalate,
Extend,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowState {
pub id: String,
pub request_id: String,
pub status: WorkflowStatus,
pub approvals: Vec<WorkflowApproval>,
pub rejections: Vec<WorkflowRejection>,
pub comments: Vec<WorkflowComment>,
pub escalation_level: u32,
pub started_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
pub result: Option<ApprovalResult>,
}
impl WorkflowState {
pub fn new(request: &ApprovalRequest, config: &WorkflowConfig) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
request_id: request.id.clone(),
status: WorkflowStatus::Pending,
approvals: Vec::new(),
rejections: Vec::new(),
comments: Vec::new(),
escalation_level: 0,
started_at: now,
expires_at: now + chrono::Duration::seconds(config.timeout_secs as i64),
completed_at: None,
result: None,
}
}
pub fn has_enough_approvals(&self, required: usize) -> bool {
self.approvals.len() >= required
}
pub fn is_expired(&self) -> bool {
Utc::now() > self.expires_at
}
pub fn is_completed(&self) -> bool {
matches!(
self.status,
WorkflowStatus::Approved | WorkflowStatus::Rejected | WorkflowStatus::Expired
)
}
pub fn add_approval(&mut self, approval: WorkflowApproval) {
self.approvals.push(approval);
}
pub fn add_rejection(&mut self, rejection: WorkflowRejection) {
self.rejections.push(rejection);
}
pub fn add_comment(&mut self, comment: WorkflowComment) {
self.comments.push(comment);
}
pub fn complete(&mut self, status: WorkflowStatus, result: ApprovalResult) {
self.status = status;
self.result = Some(result);
self.completed_at = Some(Utc::now());
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum WorkflowStatus {
Pending,
PartiallyApproved,
Approved,
Rejected,
Expired,
Escalated,
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowApproval {
pub approver: String,
pub timestamp: DateTime<Utc>,
pub comment: Option<String>,
pub conditions: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowRejection {
pub rejector: String,
pub timestamp: DateTime<Utc>,
pub reason: String,
pub alternative: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowComment {
pub author: String,
pub timestamp: DateTime<Utc>,
pub content: String,
pub is_system: bool,
}
pub struct ApprovalWorkflow {
config: WorkflowConfig,
state: WorkflowState,
policies: Vec<ApprovalPolicy>,
callbacks: WorkflowCallbacks,
}
#[derive(Default)]
pub struct WorkflowCallbacks {
pub on_approved: Option<Box<dyn Fn(&WorkflowState) + Send + Sync>>,
pub on_rejected: Option<Box<dyn Fn(&WorkflowState) + Send + Sync>>,
pub on_expired: Option<Box<dyn Fn(&WorkflowState) + Send + Sync>>,
pub on_escalated: Option<Box<dyn Fn(&WorkflowState, u32) + Send + Sync>>,
}
impl ApprovalWorkflow {
pub fn new(
request: &ApprovalRequest,
config: WorkflowConfig,
policies: Vec<ApprovalPolicy>,
) -> Self {
let state = WorkflowState::new(request, &config);
Self {
config,
state,
policies,
callbacks: WorkflowCallbacks::default(),
}
}
pub fn id(&self) -> &str {
&self.state.id
}
pub fn state(&self) -> &WorkflowState {
&self.state
}
pub fn config(&self) -> &WorkflowConfig {
&self.config
}
pub fn on_approved<F>(mut self, callback: F) -> Self
where
F: Fn(&WorkflowState) + Send + Sync + 'static,
{
self.callbacks.on_approved = Some(Box::new(callback));
self
}
pub fn on_rejected<F>(mut self, callback: F) -> Self
where
F: Fn(&WorkflowState) + Send + Sync + 'static,
{
self.callbacks.on_rejected = Some(Box::new(callback));
self
}
pub fn approve(
&mut self,
approver: impl Into<String>,
comment: Option<String>,
) -> Result<WorkflowStatus, String> {
let approver = approver.into();
for policy in &self.policies {
if !policy.can_approve(&approver) {
return Err(format!(
"Approver {} is not allowed by policy {}",
approver, policy.name
));
}
}
let approval = WorkflowApproval {
approver: approver.clone(),
timestamp: Utc::now(),
comment,
conditions: Vec::new(),
};
self.state.add_approval(approval);
self.state.add_comment(WorkflowComment {
author: "system".to_string(),
timestamp: Utc::now(),
content: format!("{} approved the request", approver),
is_system: true,
});
if self
.state
.has_enough_approvals(self.config.required_approvals)
{
let approvers: Vec<String> = self
.state
.approvals
.iter()
.map(|a| a.approver.clone())
.collect();
let all_required = self
.policies
.iter()
.all(|p| p.all_required_approved(&approvers));
if all_required {
let result = ApprovalResult {
request_id: self.state.request_id.clone(),
status: ApprovalStatus::Approved,
approved_by: Some(approvers.join(", ")),
reason: None,
timestamp: Utc::now(),
modified_action: None,
};
self.state.complete(WorkflowStatus::Approved, result);
if let Some(ref callback) = self.callbacks.on_approved {
callback(&self.state);
}
return Ok(WorkflowStatus::Approved);
}
}
self.state.status = WorkflowStatus::PartiallyApproved;
Ok(WorkflowStatus::PartiallyApproved)
}
pub fn reject(&mut self, rejector: impl Into<String>, reason: String) -> WorkflowStatus {
let rejector = rejector.into();
let rejection = WorkflowRejection {
rejector: rejector.clone(),
timestamp: Utc::now(),
reason: reason.clone(),
alternative: None,
};
self.state.add_rejection(rejection);
let result = ApprovalResult {
request_id: self.state.request_id.clone(),
status: ApprovalStatus::Rejected,
approved_by: Some(rejector.clone()),
reason: Some(reason),
timestamp: Utc::now(),
modified_action: None,
};
self.state.complete(WorkflowStatus::Rejected, result);
if let Some(ref callback) = self.callbacks.on_rejected {
callback(&self.state);
}
WorkflowStatus::Rejected
}
pub fn comment(&mut self, author: impl Into<String>, content: impl Into<String>) {
self.state.add_comment(WorkflowComment {
author: author.into(),
timestamp: Utc::now(),
content: content.into(),
is_system: false,
});
}
pub fn check_expiration(&mut self) -> Option<WorkflowStatus> {
if !self.state.is_expired() || self.state.is_completed() {
return None;
}
match self.config.timeout_action {
TimeoutAction::Reject => {
let result = ApprovalResult {
request_id: self.state.request_id.clone(),
status: ApprovalStatus::Timeout,
approved_by: None,
reason: Some("Request timed out".to_string()),
timestamp: Utc::now(),
modified_action: None,
};
self.state.complete(WorkflowStatus::Expired, result);
if let Some(ref callback) = self.callbacks.on_expired {
callback(&self.state);
}
Some(WorkflowStatus::Expired)
}
TimeoutAction::Approve => {
let result = ApprovalResult {
request_id: self.state.request_id.clone(),
status: ApprovalStatus::Approved,
approved_by: Some("system".to_string()),
reason: Some("Auto-approved after timeout".to_string()),
timestamp: Utc::now(),
modified_action: None,
};
self.state.complete(WorkflowStatus::Approved, result);
if let Some(ref callback) = self.callbacks.on_approved {
callback(&self.state);
}
Some(WorkflowStatus::Approved)
}
TimeoutAction::Escalate => {
self.escalate();
Some(WorkflowStatus::Escalated)
}
TimeoutAction::Extend => {
self.state.expires_at =
Utc::now() + chrono::Duration::seconds(self.config.timeout_secs as i64);
None
}
}
}
pub fn escalate(&mut self) -> bool {
if let Some(ref escalation) = self.config.escalation {
if self.state.escalation_level < escalation.max_levels {
self.state.escalation_level += 1;
self.state.status = WorkflowStatus::Escalated;
self.state.expires_at =
Utc::now() + chrono::Duration::seconds(escalation.escalate_after_secs as i64);
self.state.add_comment(WorkflowComment {
author: "system".to_string(),
timestamp: Utc::now(),
content: format!(
"Escalated to level {} ({})",
self.state.escalation_level,
escalation.escalate_to.join(", ")
),
is_system: true,
});
if let Some(ref callback) = self.callbacks.on_escalated {
callback(&self.state, self.state.escalation_level);
}
return true;
}
}
false
}
pub fn progress_summary(&self) -> WorkflowProgress {
WorkflowProgress {
workflow_id: self.state.id.clone(),
status: self.state.status,
approvals_received: self.state.approvals.len(),
approvals_required: self.config.required_approvals,
rejections_received: self.state.rejections.len(),
time_remaining_secs: if self.state.is_expired() {
0
} else {
(self.state.expires_at - Utc::now()).num_seconds().max(0)
},
escalation_level: self.state.escalation_level,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowProgress {
pub workflow_id: String,
pub status: WorkflowStatus,
pub approvals_received: usize,
pub approvals_required: usize,
pub rejections_received: usize,
pub time_remaining_secs: i64,
pub escalation_level: u32,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hitl::policy::{ActionType, RiskLevel};
fn create_test_request() -> ApprovalRequest {
ApprovalRequest::new("Test action", ActionType::FileDelete, RiskLevel::High)
}
#[test]
fn test_workflow_creation() {
let request = create_test_request();
let config = WorkflowConfig::default();
let workflow = ApprovalWorkflow::new(&request, config, vec![]);
assert_eq!(workflow.state().status, WorkflowStatus::Pending);
assert!(!workflow.state().is_expired());
}
#[test]
fn test_workflow_approval() {
let request = create_test_request();
let config = WorkflowConfig {
required_approvals: 1,
..Default::default()
};
let mut workflow = ApprovalWorkflow::new(&request, config, vec![]);
let status = workflow
.approve("admin", Some("Looks good".to_string()))
.unwrap();
assert_eq!(status, WorkflowStatus::Approved);
assert!(workflow.state().is_completed());
}
#[test]
fn test_workflow_multiple_approvals() {
let request = create_test_request();
let config = WorkflowConfig {
required_approvals: 2,
..Default::default()
};
let mut workflow = ApprovalWorkflow::new(&request, config, vec![]);
let status = workflow.approve("admin1", None).unwrap();
assert_eq!(status, WorkflowStatus::PartiallyApproved);
assert!(!workflow.state().is_completed());
let status = workflow.approve("admin2", None).unwrap();
assert_eq!(status, WorkflowStatus::Approved);
assert!(workflow.state().is_completed());
}
#[test]
fn test_workflow_rejection() {
let request = create_test_request();
let config = WorkflowConfig::default();
let mut workflow = ApprovalWorkflow::new(&request, config, vec![]);
let status = workflow.reject("admin", "Too risky".to_string());
assert_eq!(status, WorkflowStatus::Rejected);
assert!(workflow.state().is_completed());
let result = workflow.state().result.as_ref().unwrap();
assert_eq!(result.status, ApprovalStatus::Rejected);
}
#[test]
fn test_workflow_comments() {
let request = create_test_request();
let config = WorkflowConfig::default();
let mut workflow = ApprovalWorkflow::new(&request, config, vec![]);
workflow.comment("reviewer", "Need more context");
assert_eq!(workflow.state().comments.len(), 1);
assert_eq!(workflow.state().comments[0].content, "Need more context");
}
#[test]
fn test_workflow_progress() {
let request = create_test_request();
let config = WorkflowConfig {
required_approvals: 3,
timeout_secs: 600,
..Default::default()
};
let mut workflow = ApprovalWorkflow::new(&request, config, vec![]);
workflow.approve("admin1", None).unwrap();
let progress = workflow.progress_summary();
assert_eq!(progress.approvals_received, 1);
assert_eq!(progress.approvals_required, 3);
assert!(progress.time_remaining_secs > 0);
}
#[test]
fn test_workflow_escalation() {
let request = create_test_request();
let config = WorkflowConfig {
escalation: Some(EscalationConfig {
escalate_after_secs: 300,
escalate_to: vec!["manager".to_string()],
max_levels: 2,
}),
..Default::default()
};
let mut workflow = ApprovalWorkflow::new(&request, config, vec![]);
assert!(workflow.escalate());
assert_eq!(workflow.state().escalation_level, 1);
assert_eq!(workflow.state().status, WorkflowStatus::Escalated);
assert!(workflow.escalate());
assert_eq!(workflow.state().escalation_level, 2);
assert!(!workflow.escalate());
assert_eq!(workflow.state().escalation_level, 2);
}
#[test]
fn test_timeout_action_reject() {
let request = create_test_request();
let config = WorkflowConfig {
timeout_secs: 0, timeout_action: TimeoutAction::Reject,
..Default::default()
};
let mut workflow = ApprovalWorkflow::new(&request, config, vec![]);
workflow.state.expires_at = Utc::now() - chrono::Duration::seconds(1);
let result = workflow.check_expiration();
assert_eq!(result, Some(WorkflowStatus::Expired));
}
#[test]
fn test_policy_restrictions() {
let request = create_test_request();
let config = WorkflowConfig::default();
let policy = ApprovalPolicy::new("restricted").with_allowed_approver("admin");
let mut workflow = ApprovalWorkflow::new(&request, config, vec![policy]);
let result = workflow.approve("developer", None);
assert!(result.is_err());
let result = workflow.approve("admin", None);
assert!(result.is_ok());
}
}