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(¬_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(¬_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}