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