1use 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#[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 Initiated,
29 Accepted,
31 Rejected,
33 Completed,
35}
36
37impl HandoffStatus {
38 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 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 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#[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 pub rejection_reason: Option<String>,
114}
115
116define_state_machine! {
121 pub trait HandoffState;
123 Initiated,
125 HandoffAccepted,
127 Rejected,
129 HandoffCompleted,
131}
132
133define_typestate_wrapper! {
134 pub struct Handoff<HandoffState> wraps HandoffRecord;
143 initial_state: Initiated;
144}
145
146impl<S: HandoffState> Handoff<S> {
147 pub fn handoff_id(&self) -> HandoffId {
149 self.data.handoff_id
150 }
151
152 pub fn tenant_id(&self) -> TenantId {
154 self.data.tenant_id
155 }
156
157 pub fn from_agent_id(&self) -> AgentId {
159 self.data.from_agent_id
160 }
161
162 pub fn to_agent_id(&self) -> AgentId {
164 self.data.to_agent_id
165 }
166
167 pub fn trajectory_id(&self) -> TrajectoryId {
169 self.data.trajectory_id
170 }
171
172 pub fn scope_id(&self) -> ScopeId {
174 self.data.scope_id
175 }
176
177 pub fn reason(&self) -> &str {
179 &self.data.reason
180 }
181
182 pub fn context_snapshot(&self) -> &[u8] {
184 &self.data.context_snapshot
185 }
186
187 pub fn created_at(&self) -> Timestamp {
189 self.data.created_at
190 }
191}
192
193impl Handoff<Initiated> {
194 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 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 pub fn accepted_at(&self) -> Timestamp {
222 self.data
223 .accepted_at
224 .expect("Accepted handoff must have accepted_at")
225 }
226
227 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 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 pub fn accepted_at(&self) -> Timestamp {
253 self.data
254 .accepted_at
255 .expect("Completed handoff must have accepted_at")
256 }
257
258 pub fn completed_at(&self) -> Timestamp {
260 self.data
261 .completed_at
262 .expect("Completed handoff must have completed_at")
263 }
264}
265
266#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
275pub struct StoredHandoff {
276 pub data: HandoffRecord,
277 pub status: HandoffStatus,
278}
279
280#[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 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 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 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 pub fn data(&self) -> &HandoffRecord {
345 &self.data
346 }
347
348 pub fn status(&self) -> HandoffStatus {
350 self.status
351 }
352}
353
354#[derive(Debug, Clone, PartialEq, Eq)]
356pub enum HandoffStateError {
357 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}