Skip to main content

algocline_core/execution/
error.rs

1//! Dedicated error enums for each `ExecutionService` verb.
2//!
3//! All error types are **closed enums** (no `#[non_exhaustive]`): wire consumers
4//! must handle every variant, and the adapter layer converts to a string before
5//! crossing the MCP boundary (design-v1.md §2).
6//!
7//! All error types derive `serde::Serialize + serde::Deserialize` so they can be
8//! embedded in structured JSON responses if needed by callers.
9
10use serde::{Deserialize, Serialize};
11
12use super::session_id::SessionId;
13use super::state::ExecutionStateTag;
14use crate::FeedError;
15
16// ---------------------------------------------------------------------------
17// SpawnError
18// ---------------------------------------------------------------------------
19
20/// Error returned by [`crate::execution::ExecutionService::spawn`].
21///
22/// # Note on `#[from] EngineError`
23/// In this subtask, the engine-level error is represented as a `String` wrapper.
24/// Subtask 2 will replace `Engine(String)` with `Engine(#[from] SessionError)` once
25/// the engine's `SessionError` type is available, using `#[from]` for automatic
26/// conversion.
27#[derive(Debug, thiserror::Error, Serialize, Deserialize)]
28pub enum SpawnError {
29    /// The engine failed to start the session.
30    #[error("engine error: {0}")]
31    Engine(String),
32    /// The provided `SessionSpec` was invalid.
33    #[error("invalid spec: {0}")]
34    InvalidSpec(String),
35}
36
37// ---------------------------------------------------------------------------
38// StateError
39// ---------------------------------------------------------------------------
40
41/// Error returned by [`crate::execution::ExecutionService::state`].
42#[derive(Debug, thiserror::Error, Serialize, Deserialize)]
43pub enum StateError {
44    /// No session with the given id exists in the registry.
45    #[error("session not found: {0}")]
46    NotFound(SessionId),
47}
48
49// ---------------------------------------------------------------------------
50// ResumeError
51// ---------------------------------------------------------------------------
52
53/// Error returned by [`crate::execution::ExecutionService::resume`].
54///
55/// # Note on `#[from]` and serde compatibility
56/// `FeedError` (from `crates/algocline-core/src/state.rs`) does not derive
57/// `serde::Serialize + serde::Deserialize`, so we cannot use `#[from] FeedError`
58/// directly on a serde-enabled enum.  Instead, the `FeedError` variant stores the
59/// display string, and a manual `From<FeedError>` impl converts it.
60///
61/// Subtask 2 may add `From<SessionError>` to this enum; if a second `#[from]`
62/// conversion causes a trait impl conflict (K-79), the later conversion must use
63/// `map_err` instead.
64#[derive(Debug, thiserror::Error, Serialize, Deserialize)]
65pub enum ResumeError {
66    /// No session with the given id exists in the registry.
67    #[error("session not found: {0}")]
68    NotFound(SessionId),
69    /// The session exists but is not in the `Paused` state.
70    #[error("session is not paused; actual state: {actual_tag:?}")]
71    NotPaused {
72        /// The state the session is actually in.
73        actual_tag: ExecutionStateTag,
74    },
75    /// The session is already cancelled; resuming is a no-op that returns this error
76    /// so callers are explicitly informed.
77    #[error("session is already cancelled")]
78    AlreadyCancelled,
79    /// The feed operation on the underlying session failed.
80    ///
81    /// The original `FeedError` message is preserved as a `String` because `FeedError`
82    /// does not implement `serde::Serialize + serde::Deserialize`.
83    #[error("feed error: {0}")]
84    FeedError(String),
85}
86
87impl From<FeedError> for ResumeError {
88    /// Converts a [`FeedError`] into a [`ResumeError::FeedError`] by formatting
89    /// the error message.  This preserves the error chain for callers while
90    /// remaining serde-compatible.
91    fn from(e: FeedError) -> Self {
92        Self::FeedError(e.to_string())
93    }
94}
95
96// ---------------------------------------------------------------------------
97// CancelError
98// ---------------------------------------------------------------------------
99
100/// Error returned by [`crate::execution::ExecutionService::cancel`].
101///
102/// `cancel` is idempotent for sessions already in a terminal state: calling
103/// `cancel` on a `Done`, `Failed`, or already-`Cancelled` session returns `Ok(())`.
104/// The only error is when the session does not exist at all.
105#[derive(Debug, thiserror::Error, Serialize, Deserialize)]
106pub enum CancelError {
107    /// No session with the given id exists in the registry.
108    #[error("session not found: {0}")]
109    NotFound(SessionId),
110}
111
112// ---------------------------------------------------------------------------
113// ObserveError
114// ---------------------------------------------------------------------------
115
116/// Error returned by [`crate::execution::ExecutionService::observe`].
117#[derive(Debug, thiserror::Error, Serialize, Deserialize)]
118pub enum ObserveError {
119    /// No session with the given id exists in the registry.
120    #[error("session not found: {0}")]
121    NotFound(SessionId),
122}
123
124// ---------------------------------------------------------------------------
125// AwaitError
126// ---------------------------------------------------------------------------
127
128/// Error returned by [`crate::execution::ExecutionService::await_terminal`].
129#[derive(Debug, thiserror::Error, Serialize, Deserialize)]
130pub enum AwaitError {
131    /// No session with the given id exists in the registry.
132    #[error("session not found: {0}")]
133    NotFound(SessionId),
134    /// The background `JoinHandle` failed (e.g., the task panicked).  The string
135    /// carries the error message; `JoinHandle::abort()` is never called (invariant 4).
136    #[error("join error: {0}")]
137    Joined(String),
138}
139
140// ---------------------------------------------------------------------------
141// ObserverRecvError
142// ---------------------------------------------------------------------------
143
144/// Error returned by [`crate::execution::ObserverHandle::recv`] and
145/// [`crate::execution::ObserverHandle::try_recv`].
146#[derive(Debug, thiserror::Error, Serialize, Deserialize)]
147pub enum ObserverRecvError {
148    /// The observer fell behind and `n` events were dropped.  Subsequent calls
149    /// will continue from the most recent available event (matches
150    /// `tokio::sync::broadcast::error::RecvError::Lagged` semantics).
151    #[error("observer lagged by {0} events")]
152    Lagged(u64),
153    /// The broadcast sender was dropped (session terminated).  No further events
154    /// will be delivered.
155    #[error("broadcast channel closed")]
156    Closed,
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::query::QueryId;
163
164    #[test]
165    fn resume_error_serde_roundtrip() {
166        // Test standard variants
167        let not_found = ResumeError::NotFound(SessionId::new("sess-1".into()));
168        let json = serde_json::to_string(&not_found).expect("serialize NotFound");
169        let _: ResumeError = serde_json::from_str(&json).expect("deserialize NotFound");
170
171        let not_paused = ResumeError::NotPaused {
172            actual_tag: ExecutionStateTag::Running,
173        };
174        let json = serde_json::to_string(&not_paused).expect("serialize NotPaused");
175        let _: ResumeError = serde_json::from_str(&json).expect("deserialize NotPaused");
176
177        let already_cancelled = ResumeError::AlreadyCancelled;
178        let json = serde_json::to_string(&already_cancelled).expect("serialize AlreadyCancelled");
179        let _: ResumeError = serde_json::from_str(&json).expect("deserialize AlreadyCancelled");
180
181        // Test FeedError #[from] conversion path
182        let feed_err = FeedError::UnknownQuery(QueryId::parse("q1"));
183        let resume_err: ResumeError = feed_err.into();
184        let json = serde_json::to_string(&resume_err).expect("serialize FeedError variant");
185        let _: ResumeError = serde_json::from_str(&json).expect("deserialize FeedError variant");
186    }
187
188    #[test]
189    fn observer_recv_error_serde_roundtrip() {
190        let lagged = ObserverRecvError::Lagged(42);
191        let json = serde_json::to_string(&lagged).expect("serialize Lagged");
192        let _: ObserverRecvError = serde_json::from_str(&json).expect("deserialize Lagged");
193
194        let closed = ObserverRecvError::Closed;
195        let json = serde_json::to_string(&closed).expect("serialize Closed");
196        let _: ObserverRecvError = serde_json::from_str(&json).expect("deserialize Closed");
197    }
198
199    #[test]
200    fn all_error_enums_display() {
201        // Verify thiserror display works for all enums
202        let e = SpawnError::InvalidSpec("bad field".into());
203        assert!(e.to_string().contains("bad field"));
204
205        let e = StateError::NotFound(SessionId::new("s1".into()));
206        assert!(e.to_string().contains("s1"));
207
208        let e = CancelError::NotFound(SessionId::new("s2".into()));
209        assert!(e.to_string().contains("s2"));
210
211        let e = ObserveError::NotFound(SessionId::new("s3".into()));
212        assert!(e.to_string().contains("s3"));
213
214        let e = AwaitError::Joined("panic message".into());
215        assert!(e.to_string().contains("panic message"));
216    }
217}