use std::collections::BTreeSet;
use sha2::{Digest, Sha256};
use crate::definition::WorkflowDefinition;
use crate::error::{
ContractResult, ContractViolation, CODE_PLAN_DUPLICATE_STEP, CODE_PLAN_MISSING_STEP,
CODE_PLAN_SUCCEEDED_PIVOT, CODE_PLAN_UNKNOWN_STEP, CODE_PLAN_UNSETTLED_STEP,
};
use crate::identity::{canonical_bytes, StepName};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepForwardStatus {
Pending,
Succeeded,
Rejected,
Cancelled,
Unknown,
TimedOut,
Halted,
}
impl StepForwardStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Pending => "PENDING",
Self::Succeeded => "SUCCEEDED",
Self::Rejected => "REJECTED",
Self::Cancelled => "CANCELLED",
Self::Unknown => "UNKNOWN",
Self::TimedOut => "TIMED_OUT",
Self::Halted => "HALTED",
}
}
pub fn parse(raw: &str) -> Option<Self> {
match raw {
"PENDING" => Some(Self::Pending),
"SUCCEEDED" => Some(Self::Succeeded),
"REJECTED" => Some(Self::Rejected),
"CANCELLED" => Some(Self::Cancelled),
"UNKNOWN" => Some(Self::Unknown),
"TIMED_OUT" => Some(Self::TimedOut),
"HALTED" => Some(Self::Halted),
_ => None,
}
}
pub fn is_settled(self) -> bool {
matches!(
self,
Self::Pending | Self::Succeeded | Self::Rejected | Self::Cancelled
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StepJournalEntry {
step: StepName,
forward_status: StepForwardStatus,
}
impl StepJournalEntry {
pub fn new(step: StepName, forward_status: StepForwardStatus) -> Self {
Self {
step,
forward_status,
}
}
pub fn step(&self) -> &StepName {
&self.step
}
pub fn forward_status(&self) -> StepForwardStatus {
self.forward_status
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompensationEntry {
step: StepName,
order: u32,
}
impl CompensationEntry {
pub fn step(&self) -> &StepName {
&self.step
}
pub fn order(&self) -> u32 {
self.order
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompensationPlan {
version: String,
entries: Vec<CompensationEntry>,
}
impl CompensationPlan {
pub fn version(&self) -> &str {
&self.version
}
pub fn entries(&self) -> &[CompensationEntry] {
&self.entries
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn contains(&self, step: &StepName) -> bool {
self.entries.iter().any(|entry| entry.step() == step)
}
}
pub fn freeze_compensation_plan(
definition: &WorkflowDefinition,
journal: &[StepJournalEntry],
) -> ContractResult<CompensationPlan> {
let mut seen = BTreeSet::new();
for entry in journal {
let step = definition.step(entry.step()).ok_or_else(|| {
ContractViolation::new(
CODE_PLAN_UNKNOWN_STEP,
format!(
"journal references step `{}` absent from workflow `{}` v{}",
entry.step().as_str(),
definition.name().as_str(),
definition.version().get()
),
)
})?;
if !seen.insert(entry.step().as_str()) {
return Err(ContractViolation::new(
CODE_PLAN_DUPLICATE_STEP,
format!(
"journal contains duplicate step `{}`",
entry.step().as_str()
),
));
}
if !entry.forward_status().is_settled() {
return Err(ContractViolation::new(
CODE_PLAN_UNSETTLED_STEP,
format!(
"step `{}` is `{}` and must be resolved before freezing a compensation plan",
entry.step().as_str(),
entry.forward_status().as_str()
),
));
}
if entry.forward_status() == StepForwardStatus::Succeeded && !step.is_compensable() {
return Err(ContractViolation::new(
CODE_PLAN_SUCCEEDED_PIVOT,
format!(
"step `{}` succeeded but is non-compensable; automatic compensation must not proceed",
entry.step().as_str()
),
));
}
}
if let Some(missing) = definition
.steps()
.iter()
.find(|step| !seen.contains(step.name().as_str()))
{
return Err(ContractViolation::new(
CODE_PLAN_MISSING_STEP,
format!(
"journal is missing definition step `{}` for workflow `{}` v{}",
missing.name().as_str(),
definition.name().as_str(),
definition.version().get()
),
));
}
let mut entries = Vec::new();
for step in definition.steps().iter().rev() {
let succeeded = journal.iter().any(|entry| {
entry.step() == step.name() && entry.forward_status() == StepForwardStatus::Succeeded
});
if succeeded && step.is_compensable() {
entries.push(CompensationEntry {
step: step.name().clone(),
order: entries.len() as u32 + 1,
});
}
}
let mut hasher = Sha256::new();
hasher.update(canonical_bytes(&[
definition.digest().as_bytes(),
&(entries.len() as u64).to_be_bytes(),
]));
for entry in &entries {
hasher.update(canonical_bytes(&[
entry.step().as_str().as_bytes(),
&entry.order().to_be_bytes(),
]));
}
let version = hasher
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
Ok(CompensationPlan { version, entries })
}