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    /// No automation is configured under that id (#220).
61    #[error("automation not found: {0}")]
62    AutomationNotFound(String),
63
64    /// A request failed to prove it was allowed to make it — today, a webhook
65    /// whose HMAC signature is absent or wrong (#220). Distinct from
66    /// [`CoreError::Conflict`] and the not-found variants so the API answers
67    /// `401` instead of implying the request was merely malformed.
68    #[error("unauthorized: {0}")]
69    Unauthorized(String),
70
71    /// A request conflicts with existing state and can never succeed as-is —
72    /// e.g. registering a workspace name/root that is already taken. Distinct
73    /// from [`CoreError::Discovery`] (a *transient* reachability failure) so the
74    /// API can return `409 Conflict` rather than a retry-implying `503`.
75    #[error("{0}")]
76    Conflict(String),
77
78    /// The repo's selected provider is missing a required credential, so a
79    /// spawn would produce a doomed agent. Caught before the spawn is issued.
80    #[error("provider misconfigured: {0}")]
81    ProviderMisconfigured(String),
82
83    /// A request carried structurally invalid configuration that could never be
84    /// accepted downstream — e.g. a k8s workspace with no providers/sources,
85    /// which the apiserver would reject with a `422` (#150). Caught at the API
86    /// boundary so it surfaces as a clear `400 Bad Request` instead.
87    #[error("invalid config: {0}")]
88    InvalidConfig(String),
89
90    /// A `FleetProvider` backend's own control-plane operation failed (e.g.
91    /// `K8sFleet`'s `kube` CRUD/apply against `CalibanTask`, or its
92    /// wait-for-`Running` deadline).
93    #[error("fleet backend error: {0}")]
94    Fleet(String),
95
96    /// Generic I/O error.
97    #[error("io error: {0}")]
98    Io(#[from] std::io::Error),
99
100    /// JSON (de)serialization error.
101    #[error("json error: {0}")]
102    Json(#[from] serde_json::Error),
103}
104
105impl From<SupervisorError> for CoreError {
106    fn from(e: SupervisorError) -> Self {
107        match e {
108            SupervisorError::NotFound { id } => CoreError::AgentNotFound(id),
109            SupervisorError::InvalidState { op, id, status } => CoreError::InvalidState {
110                op,
111                id,
112                status: format!("{status:?}"),
113            },
114            SupervisorError::Internal { message } => CoreError::Protocol(message),
115        }
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn display_messages() {
125        let e = CoreError::CalibandUnreachable {
126            endpoint: "/tmp/x.sock".into(),
127            source: std::io::Error::new(std::io::ErrorKind::NotFound, "nope"),
128        };
129        assert!(
130            e.to_string()
131                .starts_with("caliband unreachable at /tmp/x.sock:")
132        );
133        assert_eq!(
134            CoreError::Protocol("bad".into()).to_string(),
135            "caliband protocol error: bad"
136        );
137        assert_eq!(
138            CoreError::AgentNotFound("a1".into()).to_string(),
139            "agent not found: a1"
140        );
141        assert_eq!(
142            CoreError::InvalidState {
143                op: "kill".into(),
144                id: "a1".into(),
145                status: "Done".into(),
146            }
147            .to_string(),
148            "invalid state for kill: agent a1 is Done"
149        );
150        assert_eq!(
151            CoreError::Discovery("d".into()).to_string(),
152            "discovery error: d"
153        );
154        assert_eq!(CoreError::Store("s".into()).to_string(), "store error: s");
155        assert_eq!(
156            CoreError::WorkspaceNotFound("r".into()).to_string(),
157            "workspace not registered: r"
158        );
159        assert_eq!(
160            CoreError::Fleet("timed out".into()).to_string(),
161            "fleet backend error: timed out"
162        );
163    }
164
165    #[test]
166    fn from_io_and_json() {
167        let io: CoreError = std::io::Error::other("boom").into();
168        assert!(matches!(io, CoreError::Io(_)));
169        assert!(io.to_string().starts_with("io error:"));
170        let json: CoreError = serde_json::from_str::<i32>("not json").unwrap_err().into();
171        assert!(matches!(json, CoreError::Json(_)));
172        assert!(json.to_string().starts_with("json error:"));
173    }
174
175    #[test]
176    fn from_supervisor_error_maps_all_arms() {
177        let nf: CoreError = SupervisorError::NotFound { id: "a1".into() }.into();
178        assert!(matches!(nf, CoreError::AgentNotFound(id) if id == "a1"));
179
180        let inv: CoreError = SupervisorError::InvalidState {
181            op: "respawn".into(),
182            id: "a2".into(),
183            // caliban's wire status (#239), not prospero's domain enum.
184            status: crate::caliband::wire::WireAgentStatus::Done,
185        }
186        .into();
187        match inv {
188            CoreError::InvalidState { op, id, status } => {
189                assert_eq!(
190                    (op.as_str(), id.as_str(), status.as_str()),
191                    ("respawn", "a2", "Done")
192                );
193            }
194            other => panic!("expected InvalidState, got {other:?}"),
195        }
196
197        let internal: CoreError = SupervisorError::Internal {
198            message: "kaboom".into(),
199        }
200        .into();
201        assert!(matches!(internal, CoreError::Protocol(m) if m == "kaboom"));
202    }
203}