Skip to main content

runifold_effect/
error.rs

1use runifold_core::{JournalError, RunError};
2use thiserror::Error;
3
4/// Normalized effect-coordination failure category.
5#[derive(Clone, Debug, Eq, PartialEq)]
6#[non_exhaustive]
7pub enum EffectExecutorErrorKind {
8    /// The owning Run lacks the required capability.
9    CapabilityDenied,
10    /// An idempotency key was reused for different work.
11    IdempotencyConflict,
12    /// Recovery cannot prove that retry is safe.
13    Ambiguous,
14    /// The effect store rejected an operation.
15    Store,
16    /// Structured event recording failed.
17    Observability,
18    /// The handler failed.
19    Handler,
20    /// Execution was cancelled.
21    Cancelled,
22    /// The effective deadline elapsed.
23    DeadlineExceeded,
24    /// Stored state violated the protocol.
25    Protocol,
26    /// A remote reconciliation query failed without resolving the effect.
27    Reconciliation,
28}
29
30/// Structured failure from effect coordination.
31#[derive(Clone, Debug, Error, PartialEq)]
32#[error("{kind:?}: {message}")]
33pub struct EffectExecutorError {
34    /// Normalized category.
35    pub kind: EffectExecutorErrorKind,
36    /// Safe failure explanation.
37    pub message: String,
38    /// Original handler error, when applicable.
39    #[source]
40    pub source_error: Option<RunError>,
41}
42
43impl EffectExecutorError {
44    /// Returns a stable diagnostic identifier without exposing error payloads.
45    ///
46    /// Resolve causes and corrective actions with `runifold ai explain <code>`.
47    /// This does not change the error's retry safety, Display or serialization.
48    pub const fn diagnostic_code(&self) -> &'static str {
49        match self.kind {
50            EffectExecutorErrorKind::CapabilityDenied => "RF-EFFECT-001",
51            EffectExecutorErrorKind::IdempotencyConflict => "RF-EFFECT-002",
52            EffectExecutorErrorKind::Ambiguous => "RF-EFFECT-003",
53            EffectExecutorErrorKind::Store => "RF-EFFECT-004",
54            EffectExecutorErrorKind::Observability => "RF-EFFECT-005",
55            EffectExecutorErrorKind::Handler => "RF-EFFECT-006",
56            EffectExecutorErrorKind::Cancelled => "RF-EFFECT-007",
57            EffectExecutorErrorKind::DeadlineExceeded => "RF-EFFECT-008",
58            EffectExecutorErrorKind::Protocol => "RF-EFFECT-009",
59            EffectExecutorErrorKind::Reconciliation => "RF-EFFECT-010",
60        }
61    }
62
63    /// Creates an error without a handler source.
64    pub fn new(kind: EffectExecutorErrorKind, message: impl Into<String>) -> Self {
65        Self {
66            kind,
67            message: message.into(),
68            source_error: None,
69        }
70    }
71
72    pub(crate) fn handler(error: RunError) -> Self {
73        Self {
74            kind: EffectExecutorErrorKind::Handler,
75            message: error.to_string(),
76            source_error: Some(error),
77        }
78    }
79
80    pub(crate) fn reconciliation(error: RunError) -> Self {
81        Self {
82            kind: EffectExecutorErrorKind::Reconciliation,
83            message: error.to_string(),
84            source_error: Some(error),
85        }
86    }
87
88    pub(crate) fn ambiguous_handler(error: RunError) -> Self {
89        Self {
90            kind: EffectExecutorErrorKind::Ambiguous,
91            message: "effect handler failed after the remote outcome became uncertain".into(),
92            source_error: Some(error),
93        }
94    }
95}
96
97impl From<JournalError> for EffectExecutorError {
98    fn from(error: JournalError) -> Self {
99        Self::new(EffectExecutorErrorKind::Observability, error.to_string())
100    }
101}