Skip to main content

lash_core/runtime/
error.rs

1/// Durable store facet that a durable execution path requires but the host
2/// wired as ephemeral.
3///
4/// Names the failing facet so a [`RuntimeErrorCode::DurableStoreRequired`]
5/// can be matched and serialized losslessly per facet.
6#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
7pub enum DurableStoreFacet {
8    AttachmentStore,
9    ArtifactStore,
10    SessionStore,
11    ProcessRegistry,
12}
13
14impl DurableStoreFacet {
15    /// Stable per-facet error-code string (the full
16    /// `durable_store_required:*` code surfaced in traces and host errors).
17    fn as_code(self) -> &'static str {
18        match self {
19            Self::AttachmentStore => "durable_store_required:attachment_store",
20            Self::ArtifactStore => "durable_store_required:artifact_store",
21            Self::SessionStore => "durable_store_required:session_store",
22            Self::ProcessRegistry => "durable_store_required:process_registry",
23        }
24    }
25}
26
27/// Stable runtime error code.
28///
29/// Codes serialize as the same snake_case strings exposed in traces and host
30/// errors, but callers should match this type instead of parsing display text.
31#[derive(Clone, Debug, PartialEq, Eq, Hash)]
32pub enum RuntimeErrorCode {
33    MissingEffectScopeId,
34    EffectScopeTurnIdMismatch,
35    /// A process (re-)execution was handed an empty/non-persisted process id.
36    /// Process execution identity is the persisted `process_id`; a retry that
37    /// cannot present that stable id has lost its idempotency anchor.
38    MissingProcessExecutionId,
39    /// A durable execution path was wired against an ephemeral store for the
40    /// named facet.
41    DurableStoreRequired {
42        facet: DurableStoreFacet,
43    },
44    StoreCommitFailed,
45    PluginSessionManager,
46    PluginFinalizeTurn,
47    PluginCheckpoint,
48    PluginPrepareTurn,
49    ContextPrepareTurn,
50    ProtocolTurnExtension,
51    ProtocolBeforeLlmCall,
52    TurnStreamJoin,
53    EmptyAgentFrameRun,
54    DurableEffectLiveProtocolExtension,
55    DurableEffectLivePluginInput,
56    Other(String),
57}
58
59impl RuntimeErrorCode {
60    pub fn as_str(&self) -> &str {
61        match self {
62            Self::MissingEffectScopeId => "missing_effect_scope_id",
63            Self::EffectScopeTurnIdMismatch => "effect_scope_turn_id_mismatch",
64            Self::MissingProcessExecutionId => "missing_process_execution_id",
65            Self::DurableStoreRequired { facet } => facet.as_code(),
66            Self::StoreCommitFailed => "store_commit_failed",
67            Self::PluginSessionManager => "plugin_session_manager",
68            Self::PluginFinalizeTurn => "plugin_finalize_turn",
69            Self::PluginCheckpoint => "plugin_checkpoint",
70            Self::PluginPrepareTurn => "plugin_prepare_turn",
71            Self::ContextPrepareTurn => "context_prepare_turn",
72            Self::ProtocolTurnExtension => "protocol_turn_extension",
73            Self::ProtocolBeforeLlmCall => "protocol_before_llm_call",
74            Self::TurnStreamJoin => "turn_stream_join",
75            Self::EmptyAgentFrameRun => "empty_agent_frame_run",
76            Self::DurableEffectLiveProtocolExtension => "durable_effect_live_protocol_extension",
77            Self::DurableEffectLivePluginInput => "durable_effect_live_plugin_input",
78            Self::Other(code) => code.as_str(),
79        }
80    }
81}
82
83impl std::fmt::Display for RuntimeErrorCode {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        f.write_str(self.as_str())
86    }
87}
88
89impl From<&str> for RuntimeErrorCode {
90    fn from(code: &str) -> Self {
91        match code {
92            "missing_effect_scope_id" => Self::MissingEffectScopeId,
93            "effect_scope_turn_id_mismatch" => Self::EffectScopeTurnIdMismatch,
94            "missing_process_execution_id" => Self::MissingProcessExecutionId,
95            "durable_store_required:attachment_store" => Self::DurableStoreRequired {
96                facet: DurableStoreFacet::AttachmentStore,
97            },
98            "durable_store_required:artifact_store" => Self::DurableStoreRequired {
99                facet: DurableStoreFacet::ArtifactStore,
100            },
101            "durable_store_required:session_store" => Self::DurableStoreRequired {
102                facet: DurableStoreFacet::SessionStore,
103            },
104            "durable_store_required:process_registry" => Self::DurableStoreRequired {
105                facet: DurableStoreFacet::ProcessRegistry,
106            },
107            "store_commit_failed" => Self::StoreCommitFailed,
108            "plugin_session_manager" => Self::PluginSessionManager,
109            "plugin_finalize_turn" => Self::PluginFinalizeTurn,
110            "plugin_checkpoint" => Self::PluginCheckpoint,
111            "plugin_prepare_turn" => Self::PluginPrepareTurn,
112            "context_prepare_turn" => Self::ContextPrepareTurn,
113            "protocol_turn_extension" => Self::ProtocolTurnExtension,
114            "protocol_before_llm_call" => Self::ProtocolBeforeLlmCall,
115            "turn_stream_join" => Self::TurnStreamJoin,
116            "empty_agent_frame_run" => Self::EmptyAgentFrameRun,
117            "durable_effect_live_protocol_extension" => Self::DurableEffectLiveProtocolExtension,
118            "durable_effect_live_plugin_input" => Self::DurableEffectLivePluginInput,
119            other => Self::Other(other.to_string()),
120        }
121    }
122}
123
124impl From<String> for RuntimeErrorCode {
125    fn from(code: String) -> Self {
126        Self::from(code.as_str())
127    }
128}
129
130impl serde::Serialize for RuntimeErrorCode {
131    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
132    where
133        S: serde::Serializer,
134    {
135        serializer.serialize_str(self.as_str())
136    }
137}
138
139impl<'de> serde::Deserialize<'de> for RuntimeErrorCode {
140    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
141    where
142        D: serde::Deserializer<'de>,
143    {
144        let code = <String as serde::Deserialize>::deserialize(deserializer)?;
145        Ok(Self::from(code))
146    }
147}
148
149/// Runtime error for unexpected failures.
150#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
151pub struct RuntimeError {
152    pub code: RuntimeErrorCode,
153    pub message: String,
154}
155
156impl RuntimeError {
157    pub fn new(code: impl Into<RuntimeErrorCode>, message: impl Into<String>) -> Self {
158        Self {
159            code: code.into(),
160            message: message.into(),
161        }
162    }
163
164    pub fn is_code(&self, code: RuntimeErrorCode) -> bool {
165        self.code == code
166    }
167
168    /// Build the loud error raised when a durable execution path was wired
169    /// against an ephemeral store for `facet`.
170    pub fn durable_store_required(facet: DurableStoreFacet) -> Self {
171        let facet_label = match facet {
172            DurableStoreFacet::AttachmentStore => "attachment store",
173            DurableStoreFacet::ArtifactStore => "lashlang artifact store",
174            DurableStoreFacet::SessionStore => "session store",
175            DurableStoreFacet::ProcessRegistry => "process registry",
176        };
177        Self::new(
178            RuntimeErrorCode::DurableStoreRequired { facet },
179            format!("durable effect hosts require a durable {facet_label}"),
180        )
181    }
182
183    /// Build the loud error raised when a process (re-)execution is handed an
184    /// empty/non-persisted id.
185    ///
186    /// Process execution identity is the persisted `process_id`, so a retry
187    /// must present that stable id — mirroring how
188    /// [`EffectScope`](crate::EffectScope) rejects an empty stable id.
189    pub fn missing_process_execution_id() -> Self {
190        Self::new(
191            RuntimeErrorCode::MissingProcessExecutionId,
192            "process execution requires a non-empty persisted process id",
193        )
194    }
195}
196
197impl std::fmt::Display for RuntimeError {
198    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199        write!(f, "{}: {}", self.code, self.message)
200    }
201}
202
203impl std::error::Error for RuntimeError {}
204
205#[cfg(test)]
206mod tests {
207    use super::{DurableStoreFacet, RuntimeError, RuntimeErrorCode};
208
209    #[test]
210    fn durable_store_required_round_trips_per_facet() {
211        for facet in [
212            DurableStoreFacet::AttachmentStore,
213            DurableStoreFacet::ArtifactStore,
214            DurableStoreFacet::SessionStore,
215            DurableStoreFacet::ProcessRegistry,
216        ] {
217            let err = RuntimeError::durable_store_required(facet);
218            let json = serde_json::to_value(&err).expect("serialize runtime error");
219            let decoded: RuntimeError = serde_json::from_value(json).expect("decode runtime error");
220            assert_eq!(
221                decoded.code,
222                RuntimeErrorCode::DurableStoreRequired { facet }
223            );
224        }
225    }
226
227    #[test]
228    fn missing_process_execution_id_round_trips() {
229        let err = RuntimeError::missing_process_execution_id();
230        assert_eq!(err.code, RuntimeErrorCode::MissingProcessExecutionId);
231        let json = serde_json::to_value(&err).expect("serialize runtime error");
232        assert_eq!(json["code"], "missing_process_execution_id");
233        let decoded: RuntimeError = serde_json::from_value(json).expect("decode runtime error");
234        assert_eq!(decoded.code, RuntimeErrorCode::MissingProcessExecutionId);
235    }
236
237    #[test]
238    fn runtime_error_code_serializes_as_stable_string() {
239        let err = RuntimeError::new(RuntimeErrorCode::StoreCommitFailed, "commit failed");
240
241        let json = serde_json::to_value(&err).expect("serialize runtime error");
242        assert_eq!(json["code"], "store_commit_failed");
243
244        let decoded: RuntimeError = serde_json::from_value(json).expect("decode runtime error");
245        assert_eq!(decoded.code, RuntimeErrorCode::StoreCommitFailed);
246    }
247
248    #[test]
249    fn unknown_runtime_error_code_round_trips() {
250        let decoded: RuntimeError = serde_json::from_value(serde_json::json!({
251            "code": "plugin_defined_abort",
252            "message": "stopped by plugin"
253        }))
254        .expect("decode plugin runtime error");
255
256        assert_eq!(
257            decoded.code,
258            RuntimeErrorCode::Other("plugin_defined_abort".to_string())
259        );
260        assert_eq!(decoded.code.as_str(), "plugin_defined_abort");
261    }
262}