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
116pub trait HandoffState: private::Sealed + Send + Sync {}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub struct Initiated;
126impl HandoffState for Initiated {}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub struct HandoffAccepted;
131impl HandoffState for HandoffAccepted {}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub struct Rejected;
136impl HandoffState for Rejected {}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub struct HandoffCompleted;
141impl HandoffState for HandoffCompleted {}
142
143mod private {
144 pub trait Sealed {}
145 impl Sealed for super::Initiated {}
146 impl Sealed for super::HandoffAccepted {}
147 impl Sealed for super::Rejected {}
148 impl Sealed for super::HandoffCompleted {}
149}
150
151#[derive(Debug, Clone)]
164pub struct Handoff<S: HandoffState> {
165 data: HandoffRecord,
166 _state: PhantomData<S>,
167}
168
169impl<S: HandoffState> Handoff<S> {
170 pub fn data(&self) -> &HandoffRecord {
172 &self.data
173 }
174
175 pub fn handoff_id(&self) -> HandoffId {
177 self.data.handoff_id
178 }
179
180 pub fn tenant_id(&self) -> TenantId {
182 self.data.tenant_id
183 }
184
185 pub fn from_agent_id(&self) -> AgentId {
187 self.data.from_agent_id
188 }
189
190 pub fn to_agent_id(&self) -> AgentId {
192 self.data.to_agent_id
193 }
194
195 pub fn trajectory_id(&self) -> TrajectoryId {
197 self.data.trajectory_id
198 }
199
200 pub fn scope_id(&self) -> ScopeId {
202 self.data.scope_id
203 }
204
205 pub fn reason(&self) -> &str {
207 &self.data.reason
208 }
209
210 pub fn context_snapshot(&self) -> &[u8] {
212 &self.data.context_snapshot
213 }
214
215 pub fn created_at(&self) -> Timestamp {
217 self.data.created_at
218 }
219
220 pub fn into_data(self) -> HandoffRecord {
222 self.data
223 }
224}
225
226impl Handoff<Initiated> {
227 pub fn new(data: HandoffRecord) -> Self {
229 Handoff {
230 data,
231 _state: PhantomData,
232 }
233 }
234
235 pub fn accept(mut self, accepted_at: Timestamp) -> Handoff<HandoffAccepted> {
240 self.data.accepted_at = Some(accepted_at);
241 Handoff {
242 data: self.data,
243 _state: PhantomData,
244 }
245 }
246
247 pub fn reject(mut self, reason: String) -> Handoff<Rejected> {
252 self.data.rejection_reason = Some(reason);
253 Handoff {
254 data: self.data,
255 _state: PhantomData,
256 }
257 }
258}
259
260impl Handoff<HandoffAccepted> {
261 pub fn accepted_at(&self) -> Timestamp {
263 self.data
264 .accepted_at
265 .expect("Accepted handoff must have accepted_at")
266 }
267
268 pub fn complete(mut self, completed_at: Timestamp) -> Handoff<HandoffCompleted> {
273 self.data.completed_at = Some(completed_at);
274 Handoff {
275 data: self.data,
276 _state: PhantomData,
277 }
278 }
279}
280
281impl Handoff<Rejected> {
282 pub fn rejection_reason(&self) -> &str {
284 self.data
285 .rejection_reason
286 .as_deref()
287 .unwrap_or("No reason provided")
288 }
289}
290
291impl Handoff<HandoffCompleted> {
292 pub fn accepted_at(&self) -> Timestamp {
294 self.data
295 .accepted_at
296 .expect("Completed handoff must have accepted_at")
297 }
298
299 pub fn completed_at(&self) -> Timestamp {
301 self.data
302 .completed_at
303 .expect("Completed handoff must have completed_at")
304 }
305}
306
307#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
316pub struct StoredHandoff {
317 pub data: HandoffRecord,
318 pub status: HandoffStatus,
319}
320
321#[derive(Debug, Clone)]
324pub enum LoadedHandoff {
325 Initiated(Handoff<Initiated>),
326 Accepted(Handoff<HandoffAccepted>),
327 Rejected(Handoff<Rejected>),
328 Completed(Handoff<HandoffCompleted>),
329}
330
331impl StoredHandoff {
332 pub fn into_typed(self) -> LoadedHandoff {
334 match self.status {
335 HandoffStatus::Initiated => LoadedHandoff::Initiated(Handoff {
336 data: self.data,
337 _state: PhantomData,
338 }),
339 HandoffStatus::Accepted => LoadedHandoff::Accepted(Handoff {
340 data: self.data,
341 _state: PhantomData,
342 }),
343 HandoffStatus::Rejected => LoadedHandoff::Rejected(Handoff {
344 data: self.data,
345 _state: PhantomData,
346 }),
347 HandoffStatus::Completed => LoadedHandoff::Completed(Handoff {
348 data: self.data,
349 _state: PhantomData,
350 }),
351 }
352 }
353
354 pub fn into_initiated(self) -> Result<Handoff<Initiated>, HandoffStateError> {
356 if self.status != HandoffStatus::Initiated {
357 return Err(HandoffStateError::WrongState {
358 handoff_id: self.data.handoff_id,
359 expected: HandoffStatus::Initiated,
360 actual: self.status,
361 });
362 }
363 Ok(Handoff {
364 data: self.data,
365 _state: PhantomData,
366 })
367 }
368
369 pub fn into_accepted(self) -> Result<Handoff<HandoffAccepted>, HandoffStateError> {
371 if self.status != HandoffStatus::Accepted {
372 return Err(HandoffStateError::WrongState {
373 handoff_id: self.data.handoff_id,
374 expected: HandoffStatus::Accepted,
375 actual: self.status,
376 });
377 }
378 Ok(Handoff {
379 data: self.data,
380 _state: PhantomData,
381 })
382 }
383
384 pub fn data(&self) -> &HandoffRecord {
386 &self.data
387 }
388
389 pub fn status(&self) -> HandoffStatus {
391 self.status
392 }
393}
394
395#[derive(Debug, Clone, PartialEq, Eq)]
397pub enum HandoffStateError {
398 WrongState {
400 handoff_id: HandoffId,
401 expected: HandoffStatus,
402 actual: HandoffStatus,
403 },
404}
405
406impl fmt::Display for HandoffStateError {
407 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
408 match self {
409 HandoffStateError::WrongState {
410 handoff_id,
411 expected,
412 actual,
413 } => {
414 write!(
415 f,
416 "Handoff {} is in state {} but expected {}",
417 handoff_id, actual, expected
418 )
419 }
420 }
421 }
422}
423
424impl std::error::Error for HandoffStateError {}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429 use crate::EntityIdType;
430 use chrono::Utc;
431
432 fn make_handoff_data() -> HandoffRecord {
433 let now = Utc::now();
434 HandoffRecord {
435 handoff_id: HandoffId::now_v7(),
436 tenant_id: TenantId::now_v7(),
437 from_agent_id: AgentId::now_v7(),
438 to_agent_id: AgentId::now_v7(),
439 trajectory_id: TrajectoryId::now_v7(),
440 scope_id: ScopeId::now_v7(),
441 reason: "Need specialist".to_string(),
442 context_snapshot: vec![1, 2, 3],
443 created_at: now,
444 accepted_at: None,
445 completed_at: None,
446 rejection_reason: None,
447 }
448 }
449
450 #[test]
451 fn test_handoff_status_roundtrip() {
452 for status in [
453 HandoffStatus::Initiated,
454 HandoffStatus::Accepted,
455 HandoffStatus::Rejected,
456 HandoffStatus::Completed,
457 ] {
458 let db_str = status.as_db_str();
459 let parsed =
460 HandoffStatus::from_db_str(db_str).expect("HandoffStatus roundtrip should succeed");
461 assert_eq!(status, parsed);
462 }
463 }
464
465 #[test]
466 fn test_handoff_accept_complete() {
467 let now = Utc::now();
468 let data = make_handoff_data();
469 let handoff = Handoff::<Initiated>::new(data);
470
471 let accepted = handoff.accept(now);
472 assert_eq!(accepted.accepted_at(), now);
473
474 let completed = accepted.complete(now);
475 assert_eq!(completed.completed_at(), now);
476 }
477
478 #[test]
479 fn test_handoff_reject() {
480 let data = make_handoff_data();
481 let handoff = Handoff::<Initiated>::new(data);
482
483 let rejected = handoff.reject("Not available".to_string());
484 assert_eq!(rejected.rejection_reason(), "Not available");
485 }
486
487 #[test]
488 fn test_stored_handoff_conversion() {
489 let data = make_handoff_data();
490 let stored = StoredHandoff {
491 data: data.clone(),
492 status: HandoffStatus::Initiated,
493 };
494
495 let initiated = stored
496 .into_initiated()
497 .expect("initiated handoff should convert successfully");
498 assert_eq!(initiated.handoff_id(), data.handoff_id);
499 }
500
501 #[test]
502 fn test_stored_handoff_wrong_state() {
503 let data = make_handoff_data();
504 let stored = StoredHandoff {
505 data,
506 status: HandoffStatus::Accepted,
507 };
508
509 assert!(matches!(
510 stored.into_initiated(),
511 Err(HandoffStateError::WrongState { .. })
512 ));
513 }
514}