Skip to main content

meerkat_mob/
error.rs

1//! Error types for mob operations.
2
3use crate::ids::{FlowId, MeerkatId, ProfileName};
4use crate::runtime::MobState;
5use crate::validate::Diagnostic;
6use crate::{MobId, RunId, StepId};
7
8/// Errors returned by mob operations.
9#[derive(Debug, thiserror::Error)]
10pub enum MobError {
11    /// The requested profile does not exist in the mob definition.
12    #[error("profile not found: {0}")]
13    ProfileNotFound(ProfileName),
14
15    /// The requested meerkat does not exist in the roster.
16    #[error("meerkat not found: {0}")]
17    MeerkatNotFound(MeerkatId),
18
19    /// A meerkat with the given ID already exists.
20    #[error("meerkat already exists: {0}")]
21    MeerkatAlreadyExists(MeerkatId),
22
23    /// The meerkat's profile does not allow external turns.
24    #[error("meerkat is not externally addressable: {0}")]
25    NotExternallyAddressable(MeerkatId),
26
27    /// The requested lifecycle state transition is invalid.
28    #[error("invalid state transition: {from} -> {to}")]
29    InvalidTransition { from: MobState, to: MobState },
30
31    /// A wiring operation failed.
32    #[error("wiring error: {0}")]
33    WiringError(String),
34
35    /// The member failed to restore durable session state and is broken until repaired.
36    #[error("member {member_id} failed to restore session {session_id}: {reason}")]
37    MemberRestoreFailed {
38        member_id: MeerkatId,
39        session_id: meerkat_core::types::SessionId,
40        reason: String,
41    },
42
43    /// Waiting for kickoff completion timed out.
44    #[error("kickoff wait timed out")]
45    KickoffWaitTimedOut { pending_member_ids: Vec<MeerkatId> },
46
47    /// The mob definition failed validation.
48    #[error("definition error: {}", format_diagnostics(.0))]
49    DefinitionError(Vec<Diagnostic>),
50
51    /// Referenced flow does not exist.
52    #[error("flow not found: {0}")]
53    FlowNotFound(FlowId),
54
55    /// Run failed with a reason.
56    #[error("flow failed for run {run_id}: {reason}")]
57    FlowFailed { run_id: RunId, reason: String },
58
59    /// Referenced run does not exist.
60    #[error("run not found: {0}")]
61    RunNotFound(RunId),
62
63    /// Run was canceled.
64    #[error("run canceled: {0}")]
65    RunCanceled(RunId),
66
67    /// Flow turn timed out while awaiting terminal transport outcome.
68    #[error("flow turn timed out")]
69    FlowTurnTimedOut,
70
71    /// Spec revision compare-and-swap failed.
72    #[error("spec revision conflict for mob {mob_id}: expected {expected:?}, actual {actual}")]
73    SpecRevisionConflict {
74        mob_id: MobId,
75        expected: Option<u64>,
76        actual: u64,
77    },
78
79    /// Schema validation failed for a step output.
80    #[error("schema validation failed for step {step_id}: {message}")]
81    SchemaValidation { step_id: StepId, message: String },
82
83    /// Not enough targets to satisfy dispatch/collection policy.
84    #[error("insufficient targets for step {step_id}: required {required}, available {available}")]
85    InsufficientTargets {
86        step_id: StepId,
87        required: u8,
88        available: usize,
89    },
90
91    /// Topology policy denied a dispatch edge.
92    #[error("topology violation: {from_role} -> {to_role}")]
93    TopologyViolation {
94        from_role: ProfileName,
95        to_role: ProfileName,
96    },
97
98    /// Supervisor escalation happened.
99    #[error("supervisor escalation: {0}")]
100    SupervisorEscalation(String),
101
102    /// Operation is not supported for the member's runtime mode.
103    #[error("unsupported for runtime mode {mode}: {reason}")]
104    UnsupportedForMode {
105        mode: crate::MobRuntimeMode,
106        reason: String,
107    },
108
109    /// Operation blocked by reset barrier.
110    #[error("reset barrier active")]
111    ResetBarrier,
112
113    /// A storage operation failed.
114    #[error("storage error: {0}")]
115    StorageError(#[source] Box<dyn std::error::Error + Send + Sync>),
116
117    /// A session service operation failed.
118    #[error("session error: {0}")]
119    SessionError(#[from] meerkat_core::service::SessionError),
120
121    /// A comms operation failed.
122    #[error("comms error: {0}")]
123    CommsError(#[from] meerkat_core::comms::SendError),
124
125    /// A runtime-backed member turn reached an external callback boundary.
126    #[error("callback pending for session {session_id} on tool '{tool_name}'")]
127    CallbackPending {
128        session_id: meerkat_core::types::SessionId,
129        tool_name: String,
130        args: serde_json::Value,
131    },
132
133    /// An internal error (unexpected state, logic errors).
134    #[error("internal error: {0}")]
135    Internal(String),
136
137    /// Operation is not yet implemented for the given storage backend.
138    ///
139    /// Callers can match on this to fall back gracefully (e.g., refuse
140    /// frame-aware flows when the selected persistence backend does not expose
141    /// the required CAS seams yet).
142    #[error("not yet implemented: {0}")]
143    NotYetImplemented(String),
144}
145
146fn format_diagnostics(diagnostics: &[Diagnostic]) -> String {
147    diagnostics
148        .iter()
149        .map(|d| format!("{}: {}", d.code, d.message))
150        .collect::<Vec<_>>()
151        .join("; ")
152}
153
154impl From<Box<dyn std::error::Error + Send + Sync>> for MobError {
155    fn from(error: Box<dyn std::error::Error + Send + Sync>) -> Self {
156        Self::StorageError(error)
157    }
158}
159
160impl From<crate::store::MobStoreError> for MobError {
161    fn from(error: crate::store::MobStoreError) -> Self {
162        match error {
163            crate::store::MobStoreError::SpecRevisionConflict {
164                mob_id,
165                expected,
166                actual,
167            } => Self::SpecRevisionConflict {
168                mob_id,
169                expected,
170                actual,
171            },
172            other => Self::StorageError(Box::new(other)),
173        }
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crate::validate::{Diagnostic, DiagnosticCode, DiagnosticSeverity};
181
182    #[test]
183    fn test_profile_not_found_display() {
184        let err = MobError::ProfileNotFound(ProfileName::from("missing"));
185        assert!(format!("{err}").contains("missing"));
186    }
187
188    #[test]
189    fn test_invalid_transition_display() {
190        let err = MobError::InvalidTransition {
191            from: MobState::Completed,
192            to: MobState::Running,
193        };
194        let msg = format!("{err}");
195        assert!(msg.contains("Completed"));
196        assert!(msg.contains("Running"));
197    }
198
199    #[test]
200    fn test_definition_error_display() {
201        let err = MobError::DefinitionError(vec![
202            Diagnostic {
203                code: DiagnosticCode::MissingSkillRef,
204                message: "skill 'foo' not found".to_string(),
205                location: Some("profiles.worker.skills[0]".to_string()),
206                severity: DiagnosticSeverity::Error,
207            },
208            Diagnostic {
209                code: DiagnosticCode::MissingMcpRef,
210                message: "mcp 'bar' not defined".to_string(),
211                location: Some("profiles.worker.tools.mcp[0]".to_string()),
212                severity: DiagnosticSeverity::Error,
213            },
214        ]);
215        let msg = format!("{err}");
216        assert!(msg.contains("missing_skill_ref"));
217        assert!(msg.contains("missing_mcp_ref"));
218    }
219
220    #[test]
221    fn test_session_error_from() {
222        let session_err = meerkat_core::service::SessionError::NotFound {
223            id: meerkat_core::types::SessionId::new(),
224        };
225        let mob_err: MobError = session_err.into();
226        assert!(matches!(mob_err, MobError::SessionError(_)));
227    }
228
229    #[test]
230    fn test_comms_error_from() {
231        let send_err = meerkat_core::comms::SendError::PeerNotFound("agent-1".to_string());
232        let mob_err: MobError = send_err.into();
233        assert!(matches!(mob_err, MobError::CommsError(_)));
234    }
235
236    #[test]
237    fn test_storage_error() {
238        let err = MobError::StorageError(Box::new(std::io::Error::new(
239            std::io::ErrorKind::Other,
240            "disk full",
241        )));
242        assert!(format!("{err}").contains("disk full"));
243    }
244
245    #[test]
246    fn test_all_variants_exist() {
247        // Ensures all variants are constructible.
248        let _variants: Vec<MobError> = vec![
249            MobError::ProfileNotFound(ProfileName::from("p")),
250            MobError::MeerkatNotFound(MeerkatId::from("m")),
251            MobError::MeerkatAlreadyExists(MeerkatId::from("m")),
252            MobError::NotExternallyAddressable(MeerkatId::from("m")),
253            MobError::InvalidTransition {
254                from: MobState::Creating,
255                to: MobState::Running,
256            },
257            MobError::WiringError("w".to_string()),
258            MobError::MemberRestoreFailed {
259                member_id: MeerkatId::from("m"),
260                session_id: meerkat_core::types::SessionId::new(),
261                reason: "restore failed".to_string(),
262            },
263            MobError::KickoffWaitTimedOut {
264                pending_member_ids: vec![MeerkatId::from("m")],
265            },
266            MobError::DefinitionError(vec![]),
267            MobError::FlowNotFound(FlowId::from("f")),
268            MobError::FlowFailed {
269                run_id: RunId::new(),
270                reason: "r".to_string(),
271            },
272            MobError::RunNotFound(RunId::new()),
273            MobError::RunCanceled(RunId::new()),
274            MobError::FlowTurnTimedOut,
275            MobError::SpecRevisionConflict {
276                mob_id: MobId::from("mob"),
277                expected: Some(2),
278                actual: 3,
279            },
280            MobError::SchemaValidation {
281                step_id: StepId::from("step"),
282                message: "invalid".to_string(),
283            },
284            MobError::InsufficientTargets {
285                step_id: StepId::from("step"),
286                required: 2,
287                available: 1,
288            },
289            MobError::TopologyViolation {
290                from_role: ProfileName::from("lead"),
291                to_role: ProfileName::from("worker"),
292            },
293            MobError::SupervisorEscalation("boom".to_string()),
294            MobError::UnsupportedForMode {
295                mode: crate::MobRuntimeMode::TurnDriven,
296                reason: "autonomous host runtime required".to_string(),
297            },
298            MobError::ResetBarrier,
299            MobError::StorageError(Box::new(std::io::Error::new(
300                std::io::ErrorKind::Other,
301                "e",
302            ))),
303            MobError::SessionError(meerkat_core::service::SessionError::PersistenceDisabled),
304            MobError::CommsError(meerkat_core::comms::SendError::PeerOffline),
305            MobError::Internal("i".to_string()),
306            MobError::NotYetImplemented("storage cas".to_string()),
307        ];
308    }
309}