cellstate_core/
handoff.rs

1//! Handoff typestate for compile-time safety of handoff lifecycle.
2//!
3//! Uses the typestate pattern to make invalid state transitions uncompilable.
4//!
5//! # State Transition Diagram
6//!
7//! ```text
8//! create() → Initiated ──┬── accept() ──→ Accepted ── complete() → Completed
9//!                        └── reject() ──→ Rejected (terminal)
10//! ```
11
12use crate::{AgentId, EnumParseError, HandoffId, ScopeId, TenantId, Timestamp, TrajectoryId};
13use serde::{Deserialize, Serialize};
14use std::fmt;
15use std::marker::PhantomData;
16use std::str::FromStr;
17
18// ============================================================================
19// HANDOFF STATUS ENUM (replaces String)
20// ============================================================================
21
22/// Status of a handoff operation.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
26pub enum HandoffStatus {
27    /// Handoff has been initiated, waiting for acceptance
28    Initiated,
29    /// Handoff was accepted by the receiving agent
30    Accepted,
31    /// Handoff was rejected by the receiving agent
32    Rejected,
33    /// Handoff has been completed successfully
34    Completed,
35}
36
37impl HandoffStatus {
38    /// Convert to database string representation.
39    pub fn as_db_str(&self) -> &'static str {
40        match self {
41            HandoffStatus::Initiated => "initiated",
42            HandoffStatus::Accepted => "accepted",
43            HandoffStatus::Rejected => "rejected",
44            HandoffStatus::Completed => "completed",
45        }
46    }
47
48    /// Parse from database string representation.
49    pub fn from_db_str(s: &str) -> Result<Self, EnumParseError> {
50        match s.to_lowercase().as_str() {
51            "initiated" | "pending" => Ok(HandoffStatus::Initiated),
52            "accepted" => Ok(HandoffStatus::Accepted),
53            "rejected" => Ok(HandoffStatus::Rejected),
54            "completed" | "complete" => Ok(HandoffStatus::Completed),
55            _ => Err(EnumParseError {
56                enum_name: "HandoffStatus",
57                input: s.to_string(),
58            }),
59        }
60    }
61
62    /// Check if this is a terminal state (no further transitions possible).
63    pub fn is_terminal(&self) -> bool {
64        matches!(self, HandoffStatus::Rejected | HandoffStatus::Completed)
65    }
66}
67
68impl fmt::Display for HandoffStatus {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        write!(f, "{}", self.as_db_str())
71    }
72}
73
74impl FromStr for HandoffStatus {
75    type Err = EnumParseError;
76
77    fn from_str(s: &str) -> Result<Self, Self::Err> {
78        Self::from_db_str(s)
79    }
80}
81
82// ============================================================================
83// HANDOFF DATA (internal storage, state-independent)
84// ============================================================================
85
86/// Internal data storage for a handoff, independent of typestate.
87/// This is what gets persisted to the database.
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
90pub struct HandoffRecord {
91    #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
92    pub handoff_id: HandoffId,
93    #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
94    pub tenant_id: TenantId,
95    #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
96    pub from_agent_id: AgentId,
97    #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
98    pub to_agent_id: AgentId,
99    #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
100    pub trajectory_id: TrajectoryId,
101    #[cfg_attr(feature = "openapi", schema(value_type = String, format = "uuid"))]
102    pub scope_id: ScopeId,
103    pub reason: String,
104    #[cfg_attr(feature = "openapi", schema(value_type = String, format = "byte"))]
105    pub context_snapshot: Vec<u8>,
106    #[cfg_attr(feature = "openapi", schema(value_type = String, format = "date-time"))]
107    pub created_at: Timestamp,
108    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, format = "date-time"))]
109    pub accepted_at: Option<Timestamp>,
110    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, format = "date-time"))]
111    pub completed_at: Option<Timestamp>,
112    /// Rejection reason (only set if rejected)
113    pub rejection_reason: Option<String>,
114}
115
116// ============================================================================
117// TYPESTATE STATE MACHINE + WRAPPER
118// ============================================================================
119
120define_state_machine! {
121    /// Marker trait for handoff states.
122    pub trait HandoffState;
123    /// Handoff has been initiated, waiting for acceptance.
124    Initiated,
125    /// Handoff was accepted by the receiving agent.
126    HandoffAccepted,
127    /// Handoff was rejected by the receiving agent (terminal).
128    Rejected,
129    /// Handoff has been completed (terminal).
130    HandoffCompleted,
131}
132
133define_typestate_wrapper! {
134    /// A handoff with compile-time state tracking.
135    ///
136    /// The type parameter `S` indicates the current state of the handoff.
137    /// Methods are only available in appropriate states:
138    /// - `Handoff<Initiated>`: Can be accepted or rejected
139    /// - `Handoff<HandoffAccepted>`: Can be completed
140    /// - `Handoff<Rejected>`: Terminal, no further transitions
141    /// - `Handoff<HandoffCompleted>`: Terminal, no further transitions
142    pub struct Handoff<HandoffState> wraps HandoffRecord;
143    initial_state: Initiated;
144}
145
146impl<S: HandoffState> Handoff<S> {
147    /// Get the handoff ID.
148    pub fn handoff_id(&self) -> HandoffId {
149        self.data.handoff_id
150    }
151
152    /// Get the tenant ID.
153    pub fn tenant_id(&self) -> TenantId {
154        self.data.tenant_id
155    }
156
157    /// Get the source agent ID.
158    pub fn from_agent_id(&self) -> AgentId {
159        self.data.from_agent_id
160    }
161
162    /// Get the target agent ID.
163    pub fn to_agent_id(&self) -> AgentId {
164        self.data.to_agent_id
165    }
166
167    /// Get the trajectory ID.
168    pub fn trajectory_id(&self) -> TrajectoryId {
169        self.data.trajectory_id
170    }
171
172    /// Get the scope ID.
173    pub fn scope_id(&self) -> ScopeId {
174        self.data.scope_id
175    }
176
177    /// Get the handoff reason.
178    pub fn reason(&self) -> &str {
179        &self.data.reason
180    }
181
182    /// Get the context snapshot.
183    pub fn context_snapshot(&self) -> &[u8] {
184        &self.data.context_snapshot
185    }
186
187    /// Get when the handoff was created.
188    pub fn created_at(&self) -> Timestamp {
189        self.data.created_at
190    }
191}
192
193impl Handoff<Initiated> {
194    /// Accept the handoff.
195    ///
196    /// Transitions to `Handoff<HandoffAccepted>`.
197    /// Consumes the current handoff.
198    pub fn accept(mut self, accepted_at: Timestamp) -> Handoff<HandoffAccepted> {
199        self.data.accepted_at = Some(accepted_at);
200        Handoff {
201            data: self.data,
202            _state: PhantomData,
203        }
204    }
205
206    /// Reject the handoff.
207    ///
208    /// Transitions to `Handoff<Rejected>` (terminal state).
209    /// Consumes the current handoff.
210    pub fn reject(mut self, reason: String) -> Handoff<Rejected> {
211        self.data.rejection_reason = Some(reason);
212        Handoff {
213            data: self.data,
214            _state: PhantomData,
215        }
216    }
217}
218
219impl Handoff<HandoffAccepted> {
220    /// Get when the handoff was accepted.
221    pub fn accepted_at(&self) -> Timestamp {
222        self.data
223            .accepted_at
224            .expect("Accepted handoff must have accepted_at")
225    }
226
227    /// Complete the handoff.
228    ///
229    /// Transitions to `Handoff<HandoffCompleted>` (terminal state).
230    /// Consumes the current handoff.
231    pub fn complete(mut self, completed_at: Timestamp) -> Handoff<HandoffCompleted> {
232        self.data.completed_at = Some(completed_at);
233        Handoff {
234            data: self.data,
235            _state: PhantomData,
236        }
237    }
238}
239
240impl Handoff<Rejected> {
241    /// Get the rejection reason.
242    pub fn rejection_reason(&self) -> &str {
243        self.data
244            .rejection_reason
245            .as_deref()
246            .unwrap_or("No reason provided")
247    }
248}
249
250impl Handoff<HandoffCompleted> {
251    /// Get when the handoff was accepted.
252    pub fn accepted_at(&self) -> Timestamp {
253        self.data
254            .accepted_at
255            .expect("Completed handoff must have accepted_at")
256    }
257
258    /// Get when the handoff was completed.
259    pub fn completed_at(&self) -> Timestamp {
260        self.data
261            .completed_at
262            .expect("Completed handoff must have completed_at")
263    }
264}
265
266// ============================================================================
267// DATABASE BOUNDARY: STORED HANDOFF
268// ============================================================================
269
270/// A handoff as stored in the database (status-agnostic).
271///
272/// When loading from the database, we don't know the state at compile time.
273/// Use the `into_*` methods to validate and convert to a typed handoff.
274#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
275pub struct StoredHandoff {
276    pub data: HandoffRecord,
277    pub status: HandoffStatus,
278}
279
280/// Enum representing all possible runtime states of a handoff.
281/// Use this when you need to handle handoffs loaded from the database.
282#[derive(Debug, Clone)]
283pub enum LoadedHandoff {
284    Initiated(Handoff<Initiated>),
285    Accepted(Handoff<HandoffAccepted>),
286    Rejected(Handoff<Rejected>),
287    Completed(Handoff<HandoffCompleted>),
288}
289
290impl StoredHandoff {
291    /// Convert to a typed handoff based on the stored status.
292    pub fn into_typed(self) -> LoadedHandoff {
293        match self.status {
294            HandoffStatus::Initiated => LoadedHandoff::Initiated(Handoff {
295                data: self.data,
296                _state: PhantomData,
297            }),
298            HandoffStatus::Accepted => LoadedHandoff::Accepted(Handoff {
299                data: self.data,
300                _state: PhantomData,
301            }),
302            HandoffStatus::Rejected => LoadedHandoff::Rejected(Handoff {
303                data: self.data,
304                _state: PhantomData,
305            }),
306            HandoffStatus::Completed => LoadedHandoff::Completed(Handoff {
307                data: self.data,
308                _state: PhantomData,
309            }),
310        }
311    }
312
313    /// Try to convert to an initiated handoff.
314    pub fn into_initiated(self) -> Result<Handoff<Initiated>, HandoffStateError> {
315        if self.status != HandoffStatus::Initiated {
316            return Err(HandoffStateError::WrongState {
317                handoff_id: self.data.handoff_id,
318                expected: HandoffStatus::Initiated,
319                actual: self.status,
320            });
321        }
322        Ok(Handoff {
323            data: self.data,
324            _state: PhantomData,
325        })
326    }
327
328    /// Try to convert to an accepted handoff.
329    pub fn into_accepted(self) -> Result<Handoff<HandoffAccepted>, HandoffStateError> {
330        if self.status != HandoffStatus::Accepted {
331            return Err(HandoffStateError::WrongState {
332                handoff_id: self.data.handoff_id,
333                expected: HandoffStatus::Accepted,
334                actual: self.status,
335            });
336        }
337        Ok(Handoff {
338            data: self.data,
339            _state: PhantomData,
340        })
341    }
342
343    /// Get the underlying data without state validation.
344    pub fn data(&self) -> &HandoffRecord {
345        &self.data
346    }
347
348    /// Get the current status.
349    pub fn status(&self) -> HandoffStatus {
350        self.status
351    }
352}
353
354/// Errors when transitioning handoff states.
355#[derive(Debug, Clone, PartialEq, Eq)]
356pub enum HandoffStateError {
357    /// Handoff is not in the expected state.
358    WrongState {
359        handoff_id: HandoffId,
360        expected: HandoffStatus,
361        actual: HandoffStatus,
362    },
363}
364
365impl fmt::Display for HandoffStateError {
366    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
367        match self {
368            HandoffStateError::WrongState {
369                handoff_id,
370                expected,
371                actual,
372            } => {
373                write!(
374                    f,
375                    "Handoff {} is in state {} but expected {}",
376                    handoff_id, actual, expected
377                )
378            }
379        }
380    }
381}
382
383impl std::error::Error for HandoffStateError {}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use crate::EntityIdType;
389    use chrono::Utc;
390
391    fn make_handoff_data() -> HandoffRecord {
392        let now = Utc::now();
393        HandoffRecord {
394            handoff_id: HandoffId::now_v7(),
395            tenant_id: TenantId::now_v7(),
396            from_agent_id: AgentId::now_v7(),
397            to_agent_id: AgentId::now_v7(),
398            trajectory_id: TrajectoryId::now_v7(),
399            scope_id: ScopeId::now_v7(),
400            reason: "Need specialist".to_string(),
401            context_snapshot: vec![1, 2, 3],
402            created_at: now,
403            accepted_at: None,
404            completed_at: None,
405            rejection_reason: None,
406        }
407    }
408
409    #[test]
410    fn test_handoff_status_roundtrip() {
411        for status in [
412            HandoffStatus::Initiated,
413            HandoffStatus::Accepted,
414            HandoffStatus::Rejected,
415            HandoffStatus::Completed,
416        ] {
417            let db_str = status.as_db_str();
418            let parsed =
419                HandoffStatus::from_db_str(db_str).expect("HandoffStatus roundtrip should succeed");
420            assert_eq!(status, parsed);
421        }
422    }
423
424    #[test]
425    fn test_handoff_accept_complete() {
426        let now = Utc::now();
427        let data = make_handoff_data();
428        let handoff = Handoff::<Initiated>::new(data);
429
430        let accepted = handoff.accept(now);
431        assert_eq!(accepted.accepted_at(), now);
432
433        let completed = accepted.complete(now);
434        assert_eq!(completed.completed_at(), now);
435    }
436
437    #[test]
438    fn test_handoff_reject() {
439        let data = make_handoff_data();
440        let handoff = Handoff::<Initiated>::new(data);
441
442        let rejected = handoff.reject("Not available".to_string());
443        assert_eq!(rejected.rejection_reason(), "Not available");
444    }
445
446    #[test]
447    fn test_stored_handoff_conversion() {
448        let data = make_handoff_data();
449        let stored = StoredHandoff {
450            data: data.clone(),
451            status: HandoffStatus::Initiated,
452        };
453
454        let initiated = stored
455            .into_initiated()
456            .expect("initiated handoff should convert successfully");
457        assert_eq!(initiated.handoff_id(), data.handoff_id);
458    }
459
460    #[test]
461    fn test_stored_handoff_wrong_state() {
462        let data = make_handoff_data();
463        let stored = StoredHandoff {
464            data,
465            status: HandoffStatus::Accepted,
466        };
467
468        assert!(matches!(
469            stored.into_initiated(),
470            Err(HandoffStateError::WrongState { .. })
471        ));
472    }
473}