artifactize-core 0.13.0

The library behind artifactize: Artifacts, evals, reviews, the monitor and review models, and the shared operations every interface calls.
Documentation
//! Payload-carrying lifecycle states; the legacy flat JSON is parsed at the edge.

use super::ExecutionResult;
use crate::types::{ExecutionStatus, FailureCode, RequestStatus, RunStatus, Timestamp};

/// Non-active, non-verdict request states historically allow an optional completion time.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PendingRequest {
    Queued,
    Blocked,
    BudgetExhausted,
    Stale,
    Unreviewed,
    WaitDependency,
}

#[derive(Debug, Clone)]
pub enum ExecutionState {
    Running,
    WaitingHuman,
    Completed {
        result: ExecutionResult,
        at: Timestamp,
    },
    Failed {
        error: String,
        code: Option<FailureCode>,
        at: Timestamp,
    },
}

/// Evidence that has passed its criteria, while a dependency sign-off is still pending.
/// The private payload keeps a RED result out of the public provisional state.
#[derive(Debug, Clone, PartialEq)]
pub struct GreenResult(ExecutionResult);

impl TryFrom<ExecutionResult> for GreenResult {
    type Error = String;
    fn try_from(result: ExecutionResult) -> Result<Self, Self::Error> {
        if result.verdict() != crate::runtime::Verdict::Green {
            return Err("A provisional request requires GREEN evidence.".into());
        }
        Ok(Self(result))
    }
}
impl AsRef<ExecutionResult> for GreenResult {
    fn as_ref(&self) -> &ExecutionResult {
        &self.0
    }
}
impl From<GreenResult> for ExecutionResult {
    fn from(result: GreenResult) -> Self {
        result.0
    }
}

#[derive(Debug, Clone)]
pub enum RequestState {
    Running,
    WaitingHuman,
    Deferred,
    Provisional {
        result: GreenResult,
        at: Timestamp,
    },
    Pending {
        kind: PendingRequest,
        at: Option<Timestamp>,
    },
    Completed {
        result: ExecutionResult,
        at: Timestamp,
    },
    Failed {
        error: String,
        code: Option<FailureCode>,
        at: Timestamp,
    },
}

impl RequestState {
    /// Construct a pending state without inventing result or error data.
    pub fn pending(status: RequestStatus, at: Option<Timestamp>) -> Result<Self, String> {
        Self::from_parts(status, None, None, None, at)
    }

    pub fn completed(result: ExecutionResult, at: Timestamp) -> Self {
        Self::Completed { result, at }
    }

    pub fn failed(error: String, code: Option<FailureCode>, at: Timestamp) -> Self {
        Self::Failed { error, code, at }
    }

    pub(super) fn from_parts(
        status: RequestStatus,
        result: Option<ExecutionResult>,
        error: Option<String>,
        code: Option<FailureCode>,
        at: Option<Timestamp>,
    ) -> Result<Self, String> {
        super::validation::result(
            status,
            result.as_ref(),
            error.as_deref(),
            code.map(FailureCode::as_str),
        )?;
        match status {
            RequestStatus::Provisional => Ok(Self::Provisional {
                result: result.expect("validated provisional GREEN").try_into()?,
                at: at.ok_or("A provisional request needs its completion time.")?,
            }),
            RequestStatus::Green | RequestStatus::Red => Ok(Self::Completed {
                result: result.expect("validated verdict"),
                at: at.ok_or("A finished request needs its completion time.")?,
            }),
            RequestStatus::Error => Ok(Self::Failed {
                error: error.expect("validated error"),
                code,
                at: at.ok_or("A finished request needs its completion time.")?,
            }),
            RequestStatus::Running | RequestStatus::WaitingHuman | RequestStatus::Deferred
                if at.is_some() =>
            {
                Err("An active request cannot have a completion time.".into())
            }
            RequestStatus::Running => Ok(Self::Running),
            RequestStatus::WaitingHuman => Ok(Self::WaitingHuman),
            RequestStatus::Deferred => Ok(Self::Deferred),
            _ => Ok(Self::Pending {
                kind: match status {
                    RequestStatus::Queued => PendingRequest::Queued,
                    RequestStatus::Blocked => PendingRequest::Blocked,
                    RequestStatus::BudgetExhausted => PendingRequest::BudgetExhausted,
                    RequestStatus::Stale => PendingRequest::Stale,
                    RequestStatus::Unreviewed => PendingRequest::Unreviewed,
                    RequestStatus::WaitDependency => PendingRequest::WaitDependency,
                    _ => unreachable!("terminal and active states handled above"),
                },
                at,
            }),
        }
    }

    pub fn status(&self) -> RequestStatus {
        match self {
            Self::Running => RequestStatus::Running,
            Self::WaitingHuman => RequestStatus::WaitingHuman,
            Self::Deferred => RequestStatus::Deferred,
            Self::Provisional { .. } => RequestStatus::Provisional,
            Self::Completed { result, .. } => match result.verdict() {
                crate::runtime::Verdict::Green => RequestStatus::Green,
                crate::runtime::Verdict::Red => RequestStatus::Red,
            },
            Self::Failed { .. } => RequestStatus::Error,
            Self::Pending { kind, .. } => match kind {
                PendingRequest::Queued => RequestStatus::Queued,
                PendingRequest::Blocked => RequestStatus::Blocked,
                PendingRequest::BudgetExhausted => RequestStatus::BudgetExhausted,
                PendingRequest::Stale => RequestStatus::Stale,
                PendingRequest::Unreviewed => RequestStatus::Unreviewed,
                PendingRequest::WaitDependency => RequestStatus::WaitDependency,
            },
        }
    }
    pub fn result(&self) -> Option<&ExecutionResult> {
        match self {
            Self::Completed { result, .. } => Some(result),
            Self::Provisional { result, .. } => Some(result.as_ref()),
            _ => None,
        }
    }
    pub fn error(&self) -> Option<&str> {
        match self {
            Self::Failed { error, .. } => Some(error),
            _ => None,
        }
    }
    pub fn error_code(&self) -> Option<FailureCode> {
        match self {
            Self::Failed { code, .. } => *code,
            _ => None,
        }
    }
    pub fn completed_at(&self) -> Option<Timestamp> {
        match self {
            Self::Completed { at, .. } | Self::Provisional { at, .. } | Self::Failed { at, .. } => {
                Some(*at)
            }
            Self::Pending { at, .. } => *at,
            _ => None,
        }
    }
}

impl ExecutionState {
    pub(super) fn from_parts(
        status: ExecutionStatus,
        result: Option<ExecutionResult>,
        error: Option<String>,
        code: Option<FailureCode>,
        at: Option<Timestamp>,
    ) -> Result<Self, String> {
        let state = RequestState::from_parts(status.into(), result, error, code, at)?;
        Self::try_from(state)
    }
    pub fn status(&self) -> ExecutionStatus {
        match self {
            Self::Running => ExecutionStatus::Running,
            Self::WaitingHuman => ExecutionStatus::WaitingHuman,
            Self::Completed { result, .. } => match result.verdict() {
                crate::runtime::Verdict::Green => ExecutionStatus::Green,
                crate::runtime::Verdict::Red => ExecutionStatus::Red,
            },
            Self::Failed { .. } => ExecutionStatus::Error,
        }
    }
    pub fn result(&self) -> Option<&ExecutionResult> {
        match self {
            Self::Completed { result, .. } => Some(result),
            _ => None,
        }
    }
    pub fn error(&self) -> Option<&str> {
        match self {
            Self::Failed { error, .. } => Some(error),
            _ => None,
        }
    }
    pub fn error_code(&self) -> Option<FailureCode> {
        match self {
            Self::Failed { code, .. } => *code,
            _ => None,
        }
    }
    pub fn completed_at(&self) -> Option<Timestamp> {
        match self {
            Self::Completed { at, .. } | Self::Failed { at, .. } => Some(*at),
            _ => None,
        }
    }
}
impl TryFrom<RequestState> for ExecutionState {
    type Error = String;
    fn try_from(state: RequestState) -> Result<Self, String> {
        match state {
            RequestState::Running => Ok(Self::Running),
            RequestState::WaitingHuman | RequestState::Deferred => Ok(Self::WaitingHuman),
            RequestState::Provisional { result, at } => Ok(Self::Completed {
                result: result.into(),
                at,
            }),
            RequestState::Completed { result, at } => Ok(Self::Completed { result, at }),
            RequestState::Failed { error, code, at } => Ok(Self::Failed { error, code, at }),
            RequestState::Pending { .. } => {
                Err("A pending request has no execution lifecycle state.".into())
            }
        }
    }
}
impl From<ExecutionState> for RequestState {
    fn from(state: ExecutionState) -> Self {
        match state {
            ExecutionState::Running => Self::Running,
            ExecutionState::WaitingHuman => Self::WaitingHuman,
            ExecutionState::Completed { result, at } => Self::Completed { result, at },
            ExecutionState::Failed { error, code, at } => Self::Failed { error, code, at },
        }
    }
}

/// Run completion data exists only in a finished lifecycle state.
#[derive(Debug, Clone)]
pub enum RunState {
    Running,
    SignoffsPending {
        at: Timestamp,
    },
    Green {
        at: Timestamp,
    },
    Red {
        at: Timestamp,
    },
    Error {
        at: Timestamp,
        error: Option<String>,
    },
    Incomplete {
        at: Timestamp,
        error: Option<String>,
    },
}

impl RunState {
    pub(super) fn from_parts(
        status: RunStatus,
        at: Option<Timestamp>,
        error: Option<String>,
    ) -> Result<Self, String> {
        match status {
            RunStatus::Running if at.is_none() && error.is_none() => Ok(Self::Running),
            RunStatus::Running => {
                Err("A running Run cannot be completed or carry an error.".into())
            }
            RunStatus::Green | RunStatus::Red | RunStatus::SignoffsPending
                if error.is_some() || at.is_none() =>
            {
                Err(
                    "A completed verdict Run needs a completion time and cannot carry an error."
                        .into(),
                )
            }
            RunStatus::SignoffsPending => Ok(Self::SignoffsPending {
                at: at.expect("validated completion time"),
            }),
            RunStatus::Green => Ok(Self::Green {
                at: at.expect("validated completion time"),
            }),
            RunStatus::Red => Ok(Self::Red {
                at: at.expect("validated completion time"),
            }),
            RunStatus::Error => Ok(Self::Error {
                at: at.ok_or("A finished Run needs its completion time.")?,
                error,
            }),
            RunStatus::Incomplete => Ok(Self::Incomplete {
                at: at.ok_or("A finished Run needs its completion time.")?,
                error,
            }),
        }
    }
    pub fn status(&self) -> RunStatus {
        match self {
            Self::Running => RunStatus::Running,
            Self::SignoffsPending { .. } => RunStatus::SignoffsPending,
            Self::Green { .. } => RunStatus::Green,
            Self::Red { .. } => RunStatus::Red,
            Self::Error { .. } => RunStatus::Error,
            Self::Incomplete { .. } => RunStatus::Incomplete,
        }
    }
    pub fn completed_at(&self) -> Option<Timestamp> {
        match self {
            Self::Running => None,
            Self::SignoffsPending { at }
            | Self::Green { at }
            | Self::Red { at }
            | Self::Error { at, .. }
            | Self::Incomplete { at, .. } => Some(*at),
        }
    }
    pub fn error(&self) -> Option<&str> {
        match self {
            Self::Error { error, .. } | Self::Incomplete { error, .. } => error.as_deref(),
            _ => None,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn provisional_constructor_rejects_red_and_preserves_green_evidence_when_promoted() {
        let red = ExecutionResult::try_from(json!({"verdict":"RED","reason":"failed"})).unwrap();
        assert!(GreenResult::try_from(red).is_err());
        let expected = json!({"verdict":"GREEN","summary":"criteria met"});
        let green = ExecutionResult::try_from(expected.clone()).unwrap();
        let provisional = RequestState::Provisional {
            result: green.try_into().unwrap(),
            at: "2026-01-01T00:00:00Z".parse().unwrap(),
        };
        assert_eq!(provisional.status(), RequestStatus::Provisional);
        assert_eq!(provisional.result().unwrap().to_json(), expected);
        let execution = ExecutionState::try_from(provisional).unwrap();
        assert_eq!(execution.status(), ExecutionStatus::Green);
        assert_eq!(execution.result().unwrap().to_json(), expected);
    }
}