lifeloop-cli 0.3.1

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! Token-safe renewal automation status.

use serde::{Deserialize, Serialize};

use crate::{FailureClass, RetryClass, SCHEMA_VERSION, ValidationError, require_non_empty};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RenewalAutomationState {
    NotAttempted,
    NoRenewal,
    PrepareStarted,
    ReceiptWritten,
    LeaseCreated,
    PendingContinuation,
    Fulfilled,
    Failed,
    Expired,
}

impl RenewalAutomationState {
    pub const ALL: &'static [Self] = &[
        Self::NotAttempted,
        Self::NoRenewal,
        Self::PrepareStarted,
        Self::ReceiptWritten,
        Self::LeaseCreated,
        Self::PendingContinuation,
        Self::Fulfilled,
        Self::Failed,
        Self::Expired,
    ];
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RenewalAutomationStatus {
    pub schema_version: String,
    pub state: RenewalAutomationState,
    pub client_id: String,
    pub adapter_id: String,
    pub updated_at_epoch_s: u64,
    pub pending_token_present: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reset_path: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub thread_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub renewal_lease_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub prepared_at_epoch_s: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub fulfilled_at_epoch_s: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pending_path: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reset_prepare_receipt_path: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub failure_class: Option<FailureClass>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub retry_class: Option<RetryClass>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
}

impl RenewalAutomationStatus {
    pub fn validate(&self) -> Result<(), ValidationError> {
        if self.schema_version != SCHEMA_VERSION {
            return Err(ValidationError::SchemaVersionMismatch {
                expected: SCHEMA_VERSION.to_string(),
                found: self.schema_version.clone(),
            });
        }
        require_non_empty(&self.client_id, "renewal_status.client_id")?;
        require_non_empty(&self.adapter_id, "renewal_status.adapter_id")?;
        for (field, value) in [
            ("renewal_status.reset_path", &self.reset_path),
            ("renewal_status.thread_id", &self.thread_id),
            ("renewal_status.renewal_lease_id", &self.renewal_lease_id),
            ("renewal_status.pending_path", &self.pending_path),
            (
                "renewal_status.reset_prepare_receipt_path",
                &self.reset_prepare_receipt_path,
            ),
            ("renewal_status.message", &self.message),
        ] {
            if let Some(value) = value {
                require_non_empty(value, field)?;
            }
        }

        let failure_like = matches!(
            self.state,
            RenewalAutomationState::Failed | RenewalAutomationState::Expired
        );
        match (failure_like, self.failure_class, self.retry_class) {
            (true, Some(_), Some(_)) => {}
            (true, _, _) => {
                return Err(ValidationError::InvalidRequest(
                    "renewal_status state=failed|expired requires failure_class and retry_class"
                        .into(),
                ));
            }
            (false, None, None) => {}
            (false, _, _) => {
                return Err(ValidationError::InvalidRequest(
                    "failure_class and retry_class are only valid for failed or expired renewal status"
                        .into(),
                ));
            }
        }

        if matches!(self.state, RenewalAutomationState::PendingContinuation)
            && !self.pending_token_present
        {
            return Err(ValidationError::InvalidRequest(
                "pending_continuation requires pending_token_present=true".into(),
            ));
        }

        Ok(())
    }
}