Skip to main content

prospero_core/
error.rs

1//! Error types for the orchestration core.
2//!
3//! A failure in one repo or agent must never abort the daemon, so most
4//! call sites surface these as state rather than propagating panics.
5
6use crate::caliband::wire::SupervisorError;
7
8/// The result type used throughout `prospero-core`.
9pub type Result<T> = std::result::Result<T, CoreError>;
10
11/// Errors produced by the orchestration core.
12#[derive(thiserror::Error, Debug)]
13pub enum CoreError {
14    /// A caliban supervisor endpoint could not be reached.
15    #[error("caliband unreachable at {endpoint}: {source}")]
16    CalibandUnreachable {
17        /// The endpoint we tried to connect to (display form).
18        endpoint: String,
19        /// Underlying I/O error.
20        source: std::io::Error,
21    },
22
23    /// A reply could not be parsed, or violated the protocol.
24    #[error("caliband protocol error: {0}")]
25    Protocol(String),
26
27    /// The supervisor reported there is no such agent.
28    #[error("agent not found: {0}")]
29    AgentNotFound(String),
30
31    /// The agent was in the wrong state for the requested operation.
32    #[error("invalid state for {op}: agent {id} is {status}")]
33    InvalidState {
34        /// The operation that was attempted.
35        op: String,
36        /// The target agent id.
37        id: String,
38        /// The agent's actual status (rendered).
39        status: String,
40    },
41
42    /// Repo discovery (socket resolution / daemon autostart) failed.
43    #[error("discovery error: {0}")]
44    Discovery(String),
45
46    /// The durable event store failed.
47    #[error("store error: {0}")]
48    Store(String),
49
50    /// An append lost the race for a per-stream `seq` to a concurrent writer
51    /// (another replica). Distinct from [`CoreError::Store`] so the emitter can
52    /// re-seed from the durable high-water and retry rather than drop the event.
53    #[error("event seq conflict")]
54    SeqConflict,
55
56    /// A workspace name was not registered.
57    #[error("workspace not registered: {0}")]
58    WorkspaceNotFound(String),
59
60    /// A request conflicts with existing state and can never succeed as-is —
61    /// e.g. registering a workspace name/root that is already taken. Distinct
62    /// from [`CoreError::Discovery`] (a *transient* reachability failure) so the
63    /// API can return `409 Conflict` rather than a retry-implying `503`.
64    #[error("{0}")]
65    Conflict(String),
66
67    /// The repo's selected provider is missing a required credential, so a
68    /// spawn would produce a doomed agent. Caught before the spawn is issued.
69    #[error("provider misconfigured: {0}")]
70    ProviderMisconfigured(String),
71
72    /// A request carried structurally invalid configuration that could never be
73    /// accepted downstream — e.g. a k8s workspace with no providers/sources,
74    /// which the apiserver would reject with a `422` (#150). Caught at the API
75    /// boundary so it surfaces as a clear `400 Bad Request` instead.
76    #[error("invalid config: {0}")]
77    InvalidConfig(String),
78
79    /// A `FleetProvider` backend's own control-plane operation failed (e.g.
80    /// `K8sFleet`'s `kube` CRUD/apply against `CalibanTask`, or its
81    /// wait-for-`Running` deadline).
82    #[error("fleet backend error: {0}")]
83    Fleet(String),
84
85    /// Generic I/O error.
86    #[error("io error: {0}")]
87    Io(#[from] std::io::Error),
88
89    /// JSON (de)serialization error.
90    #[error("json error: {0}")]
91    Json(#[from] serde_json::Error),
92}
93
94impl From<SupervisorError> for CoreError {
95    fn from(e: SupervisorError) -> Self {
96        match e {
97            SupervisorError::NotFound { id } => CoreError::AgentNotFound(id),
98            SupervisorError::InvalidState { op, id, status } => CoreError::InvalidState {
99                op,
100                id,
101                status: format!("{status:?}"),
102            },
103            SupervisorError::Internal { message } => CoreError::Protocol(message),
104        }
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::model::AgentStatus;
112
113    #[test]
114    fn display_messages() {
115        let e = CoreError::CalibandUnreachable {
116            endpoint: "/tmp/x.sock".into(),
117            source: std::io::Error::new(std::io::ErrorKind::NotFound, "nope"),
118        };
119        assert!(
120            e.to_string()
121                .starts_with("caliband unreachable at /tmp/x.sock:")
122        );
123        assert_eq!(
124            CoreError::Protocol("bad".into()).to_string(),
125            "caliband protocol error: bad"
126        );
127        assert_eq!(
128            CoreError::AgentNotFound("a1".into()).to_string(),
129            "agent not found: a1"
130        );
131        assert_eq!(
132            CoreError::InvalidState {
133                op: "kill".into(),
134                id: "a1".into(),
135                status: "Done".into(),
136            }
137            .to_string(),
138            "invalid state for kill: agent a1 is Done"
139        );
140        assert_eq!(
141            CoreError::Discovery("d".into()).to_string(),
142            "discovery error: d"
143        );
144        assert_eq!(CoreError::Store("s".into()).to_string(), "store error: s");
145        assert_eq!(
146            CoreError::WorkspaceNotFound("r".into()).to_string(),
147            "workspace not registered: r"
148        );
149        assert_eq!(
150            CoreError::Fleet("timed out".into()).to_string(),
151            "fleet backend error: timed out"
152        );
153    }
154
155    #[test]
156    fn from_io_and_json() {
157        let io: CoreError = std::io::Error::other("boom").into();
158        assert!(matches!(io, CoreError::Io(_)));
159        assert!(io.to_string().starts_with("io error:"));
160        let json: CoreError = serde_json::from_str::<i32>("not json").unwrap_err().into();
161        assert!(matches!(json, CoreError::Json(_)));
162        assert!(json.to_string().starts_with("json error:"));
163    }
164
165    #[test]
166    fn from_supervisor_error_maps_all_arms() {
167        let nf: CoreError = SupervisorError::NotFound { id: "a1".into() }.into();
168        assert!(matches!(nf, CoreError::AgentNotFound(id) if id == "a1"));
169
170        let inv: CoreError = SupervisorError::InvalidState {
171            op: "respawn".into(),
172            id: "a2".into(),
173            status: AgentStatus::Done,
174        }
175        .into();
176        match inv {
177            CoreError::InvalidState { op, id, status } => {
178                assert_eq!(
179                    (op.as_str(), id.as_str(), status.as_str()),
180                    ("respawn", "a2", "Done")
181                );
182            }
183            other => panic!("expected InvalidState, got {other:?}"),
184        }
185
186        let internal: CoreError = SupervisorError::Internal {
187            message: "kaboom".into(),
188        }
189        .into();
190        assert!(matches!(internal, CoreError::Protocol(m) if m == "kaboom"));
191    }
192}