1use serde::{Deserialize, Serialize};
19
20use crate::artifact::Artifact;
21use crate::message::Message;
22
23#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
32pub struct TaskId(pub String);
33
34impl TaskId {
35 #[must_use]
40 pub fn new(s: impl Into<String>) -> Self {
41 Self(s.into())
42 }
43
44 pub fn try_new(s: impl Into<String>) -> Result<Self, &'static str> {
50 let s = s.into();
51 if s.trim().is_empty() {
52 Err("TaskId must not be empty or whitespace-only")
53 } else {
54 Ok(Self(s))
55 }
56 }
57}
58
59impl std::fmt::Display for TaskId {
60 #[inline]
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 f.write_str(&self.0)
63 }
64}
65
66impl From<String> for TaskId {
69 fn from(s: String) -> Self {
70 Self(s)
71 }
72}
73
74impl From<&str> for TaskId {
77 fn from(s: &str) -> Self {
78 Self(s.to_owned())
79 }
80}
81
82impl AsRef<str> for TaskId {
83 #[inline]
84 fn as_ref(&self) -> &str {
85 &self.0
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
98pub struct ContextId(pub String);
99
100impl ContextId {
101 #[must_use]
106 pub fn new(s: impl Into<String>) -> Self {
107 Self(s.into())
108 }
109
110 pub fn try_new(s: impl Into<String>) -> Result<Self, &'static str> {
116 let s = s.into();
117 if s.trim().is_empty() {
118 Err("ContextId must not be empty or whitespace-only")
119 } else {
120 Ok(Self(s))
121 }
122 }
123}
124
125impl std::fmt::Display for ContextId {
126 #[inline]
127 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128 f.write_str(&self.0)
129 }
130}
131
132impl From<String> for ContextId {
135 fn from(s: String) -> Self {
136 Self(s)
137 }
138}
139
140impl From<&str> for ContextId {
143 fn from(s: &str) -> Self {
144 Self(s.to_owned())
145 }
146}
147
148impl AsRef<str> for ContextId {
149 #[inline]
150 fn as_ref(&self) -> &str {
151 &self.0
152 }
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
161pub struct TaskVersion(pub u64);
162
163impl TaskVersion {
164 #[must_use]
166 pub const fn new(v: u64) -> Self {
167 Self(v)
168 }
169
170 #[must_use]
172 pub const fn get(self) -> u64 {
173 self.0
174 }
175}
176
177impl std::fmt::Display for TaskVersion {
178 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179 write!(f, "{}", self.0)
180 }
181}
182
183impl From<u64> for TaskVersion {
184 fn from(v: u64) -> Self {
185 Self(v)
186 }
187}
188
189#[non_exhaustive]
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
198pub enum TaskState {
199 #[serde(rename = "TASK_STATE_UNSPECIFIED", alias = "unspecified")]
201 Unspecified,
202 #[serde(rename = "TASK_STATE_SUBMITTED", alias = "submitted")]
204 Submitted,
205 #[serde(rename = "TASK_STATE_WORKING", alias = "working")]
207 Working,
208 #[serde(rename = "TASK_STATE_INPUT_REQUIRED", alias = "input-required")]
210 InputRequired,
211 #[serde(rename = "TASK_STATE_AUTH_REQUIRED", alias = "auth-required")]
213 AuthRequired,
214 #[serde(rename = "TASK_STATE_COMPLETED", alias = "completed")]
216 Completed,
217 #[serde(rename = "TASK_STATE_FAILED", alias = "failed")]
219 Failed,
220 #[serde(rename = "TASK_STATE_CANCELED", alias = "canceled")]
222 Canceled,
223 #[serde(rename = "TASK_STATE_REJECTED", alias = "rejected")]
225 Rejected,
226}
227
228impl TaskState {
229 #[inline]
233 #[must_use]
234 pub const fn is_terminal(self) -> bool {
235 matches!(
236 self,
237 Self::Completed | Self::Failed | Self::Canceled | Self::Rejected
238 )
239 }
240
241 #[inline]
247 #[must_use]
248 pub const fn is_interrupted(self) -> bool {
249 matches!(self, Self::InputRequired | Self::AuthRequired)
250 }
251
252 #[inline]
258 #[must_use]
259 pub const fn can_transition_to(self, next: Self) -> bool {
260 if self.is_terminal() {
262 return false;
263 }
264 if matches!(self, Self::Unspecified) {
266 return true;
267 }
268 matches!(
269 (self, next),
270 (Self::Submitted, Self::Working | Self::Failed | Self::Canceled | Self::Rejected)
272 | (Self::Working,
276 Self::Working | Self::Completed | Self::Failed | Self::Canceled | Self::InputRequired | Self::AuthRequired)
277 | (Self::InputRequired | Self::AuthRequired,
279 Self::Working | Self::Failed | Self::Canceled)
280 )
281 }
282}
283
284impl std::fmt::Display for TaskState {
285 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286 let s = match self {
287 Self::Unspecified => "TASK_STATE_UNSPECIFIED",
288 Self::Submitted => "TASK_STATE_SUBMITTED",
289 Self::Working => "TASK_STATE_WORKING",
290 Self::InputRequired => "TASK_STATE_INPUT_REQUIRED",
291 Self::AuthRequired => "TASK_STATE_AUTH_REQUIRED",
292 Self::Completed => "TASK_STATE_COMPLETED",
293 Self::Failed => "TASK_STATE_FAILED",
294 Self::Canceled => "TASK_STATE_CANCELED",
295 Self::Rejected => "TASK_STATE_REJECTED",
296 };
297 f.write_str(s)
298 }
299}
300
301#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
306#[serde(rename_all = "camelCase")]
307pub struct TaskStatus {
308 pub state: TaskState,
310
311 #[serde(skip_serializing_if = "Option::is_none")]
313 pub message: Option<Message>,
314
315 #[serde(skip_serializing_if = "Option::is_none")]
317 pub timestamp: Option<String>,
318}
319
320impl TaskStatus {
321 #[must_use]
326 pub const fn new(state: TaskState) -> Self {
327 Self {
328 state,
329 message: None,
330 timestamp: None,
331 }
332 }
333
334 #[must_use]
336 pub fn with_timestamp(state: TaskState) -> Self {
337 Self {
338 state,
339 message: None,
340 timestamp: Some(crate::utc_now_iso8601()),
341 }
342 }
343
344 #[must_use]
350 pub fn has_valid_timestamp(&self) -> bool {
351 self.timestamp
354 .as_ref()
355 .is_none_or(|ts| ts.len() >= 19 && ts.contains('T'))
356 }
357}
358
359#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
369#[serde(rename_all = "camelCase")]
370pub struct Task {
371 pub id: TaskId,
373
374 pub context_id: ContextId,
376
377 pub status: TaskStatus,
379
380 #[serde(skip_serializing_if = "Option::is_none")]
382 pub history: Option<Vec<Message>>,
383
384 #[serde(skip_serializing_if = "Option::is_none")]
386 pub artifacts: Option<Vec<Artifact>>,
387
388 #[serde(skip_serializing_if = "Option::is_none")]
390 pub metadata: Option<serde_json::Value>,
391}
392
393#[cfg(test)]
396mod tests {
397 use super::*;
398
399 fn make_task() -> Task {
400 Task {
401 id: TaskId::new("task-1"),
402 context_id: ContextId::new("ctx-1"),
403 status: TaskStatus::new(TaskState::Working),
404 history: None,
405 artifacts: None,
406 metadata: None,
407 }
408 }
409
410 #[test]
411 fn task_state_screaming_snake_serde() {
412 assert_eq!(
413 serde_json::to_string(&TaskState::InputRequired).expect("ser"),
414 "\"TASK_STATE_INPUT_REQUIRED\""
415 );
416 assert_eq!(
417 serde_json::to_string(&TaskState::AuthRequired).expect("ser"),
418 "\"TASK_STATE_AUTH_REQUIRED\""
419 );
420 assert_eq!(
421 serde_json::to_string(&TaskState::Submitted).expect("ser"),
422 "\"TASK_STATE_SUBMITTED\""
423 );
424 assert_eq!(
425 serde_json::to_string(&TaskState::Unspecified).expect("ser"),
426 "\"TASK_STATE_UNSPECIFIED\""
427 );
428 let back: TaskState = serde_json::from_str("\"completed\"").unwrap();
430 assert_eq!(back, TaskState::Completed);
431 let back: TaskState = serde_json::from_str("\"input-required\"").unwrap();
432 assert_eq!(back, TaskState::InputRequired);
433 }
434
435 #[test]
436 fn task_state_is_terminal() {
437 assert!(TaskState::Completed.is_terminal());
438 assert!(TaskState::Failed.is_terminal());
439 assert!(TaskState::Canceled.is_terminal());
440 assert!(TaskState::Rejected.is_terminal());
441 assert!(!TaskState::Working.is_terminal());
442 assert!(!TaskState::Submitted.is_terminal());
443 }
444
445 #[test]
446 fn task_roundtrip() {
447 let task = make_task();
448 let json = serde_json::to_string(&task).expect("serialize");
449 assert!(json.contains("\"id\":\"task-1\""));
450
451 let back: Task = serde_json::from_str(&json).expect("deserialize");
452 assert_eq!(back.id, TaskId::new("task-1"));
453 assert_eq!(back.context_id, ContextId::new("ctx-1"));
454 assert_eq!(back.status.state, TaskState::Working);
455 }
456
457 #[test]
458 fn optional_fields_omitted() {
459 let task = make_task();
460 let json = serde_json::to_string(&task).expect("serialize");
461 assert!(!json.contains("\"history\""), "history should be omitted");
462 assert!(
463 !json.contains("\"artifacts\""),
464 "artifacts should be omitted"
465 );
466 assert!(!json.contains("\"metadata\""), "metadata should be omitted");
467 }
468
469 #[test]
470 fn task_version_ordering() {
471 assert!(TaskVersion::new(2) > TaskVersion::new(1));
472 assert_eq!(TaskVersion::new(5).get(), 5);
473 }
474
475 #[test]
476 fn wire_format_submitted_state() {
477 let json = serde_json::to_string(&TaskState::Submitted).unwrap();
478 assert_eq!(json, "\"TASK_STATE_SUBMITTED\"");
479
480 let back: TaskState = serde_json::from_str("\"submitted\"").unwrap();
482 assert_eq!(back, TaskState::Submitted);
483 let back: TaskState = serde_json::from_str("\"TASK_STATE_SUBMITTED\"").unwrap();
484 assert_eq!(back, TaskState::Submitted);
485 }
486
487 #[test]
488 fn task_version_serde_roundtrip() {
489 let v = TaskVersion::new(42);
490 let json = serde_json::to_string(&v).expect("serialize");
491 assert_eq!(json, "42");
492
493 let back: TaskVersion = serde_json::from_str(&json).expect("deserialize");
494 assert_eq!(back, TaskVersion::new(42));
495
496 let v0 = TaskVersion::new(0);
498 let json0 = serde_json::to_string(&v0).expect("serialize zero");
499 assert_eq!(json0, "0");
500 let back0: TaskVersion = serde_json::from_str(&json0).expect("deserialize zero");
501 assert_eq!(back0, TaskVersion::new(0));
502
503 let vmax = TaskVersion::new(u64::MAX);
505 let json_max = serde_json::to_string(&vmax).expect("serialize max");
506 let back_max: TaskVersion = serde_json::from_str(&json_max).expect("deserialize max");
507 assert_eq!(back_max, vmax);
508 }
509
510 #[test]
511 fn empty_string_ids_work() {
512 let tid = TaskId::new("");
513 let json = serde_json::to_string(&tid).expect("serialize empty TaskId");
514 assert_eq!(json, "\"\"");
515 let back: TaskId = serde_json::from_str(&json).expect("deserialize empty TaskId");
516 assert_eq!(back, TaskId::new(""));
517
518 let cid = ContextId::new("");
519 let json = serde_json::to_string(&cid).expect("serialize empty ContextId");
520 assert_eq!(json, "\"\"");
521 let back: ContextId = serde_json::from_str(&json).expect("deserialize empty ContextId");
522 assert_eq!(back, ContextId::new(""));
523
524 let task = Task {
526 id: TaskId::new(""),
527 context_id: ContextId::new(""),
528 status: TaskStatus::new(TaskState::Submitted),
529 history: None,
530 artifacts: None,
531 metadata: None,
532 };
533 let json = serde_json::to_string(&task).expect("serialize task with empty ids");
534 let back: Task = serde_json::from_str(&json).expect("deserialize task with empty ids");
535 assert_eq!(back.id, TaskId::new(""));
536 assert_eq!(back.context_id, ContextId::new(""));
537 }
538
539 #[test]
540 fn task_state_display_trait() {
541 assert_eq!(TaskState::Working.to_string(), "TASK_STATE_WORKING");
542 assert_eq!(TaskState::Completed.to_string(), "TASK_STATE_COMPLETED");
543 assert_eq!(TaskState::Failed.to_string(), "TASK_STATE_FAILED");
544 assert_eq!(TaskState::Canceled.to_string(), "TASK_STATE_CANCELED");
545 assert_eq!(TaskState::Rejected.to_string(), "TASK_STATE_REJECTED");
546 assert_eq!(TaskState::Submitted.to_string(), "TASK_STATE_SUBMITTED");
547 assert_eq!(
548 TaskState::InputRequired.to_string(),
549 "TASK_STATE_INPUT_REQUIRED"
550 );
551 assert_eq!(
552 TaskState::AuthRequired.to_string(),
553 "TASK_STATE_AUTH_REQUIRED"
554 );
555 assert_eq!(TaskState::Unspecified.to_string(), "TASK_STATE_UNSPECIFIED");
556 }
557
558 #[test]
561 fn is_terminal_all_variants() {
562 assert!(!TaskState::Unspecified.is_terminal());
563 assert!(!TaskState::Submitted.is_terminal());
564 assert!(!TaskState::Working.is_terminal());
565 assert!(!TaskState::InputRequired.is_terminal());
566 assert!(!TaskState::AuthRequired.is_terminal());
567 assert!(TaskState::Completed.is_terminal());
568 assert!(TaskState::Failed.is_terminal());
569 assert!(TaskState::Canceled.is_terminal());
570 assert!(TaskState::Rejected.is_terminal());
571 }
572
573 #[test]
581 fn is_interrupted_all_variants() {
582 assert!(!TaskState::Unspecified.is_interrupted());
583 assert!(!TaskState::Submitted.is_interrupted());
584 assert!(!TaskState::Working.is_interrupted());
585 assert!(TaskState::InputRequired.is_interrupted());
586 assert!(TaskState::AuthRequired.is_interrupted());
587 assert!(!TaskState::Completed.is_interrupted());
588 assert!(!TaskState::Failed.is_interrupted());
589 assert!(!TaskState::Canceled.is_interrupted());
590 assert!(!TaskState::Rejected.is_interrupted());
591 }
592
593 #[test]
597 fn can_transition_to_valid_transitions() {
598 use TaskState::*;
599
600 for &target in &[
602 Unspecified,
603 Submitted,
604 Working,
605 InputRequired,
606 AuthRequired,
607 Completed,
608 Failed,
609 Canceled,
610 Rejected,
611 ] {
612 assert!(
613 Unspecified.can_transition_to(target),
614 "Unspecified → {target:?} should be valid"
615 );
616 }
617
618 assert!(Submitted.can_transition_to(Working));
620 assert!(Submitted.can_transition_to(Failed));
621 assert!(Submitted.can_transition_to(Canceled));
622 assert!(Submitted.can_transition_to(Rejected));
623
624 assert!(Working.can_transition_to(Completed));
626 assert!(Working.can_transition_to(Failed));
627 assert!(Working.can_transition_to(Canceled));
628 assert!(Working.can_transition_to(InputRequired));
629 assert!(Working.can_transition_to(AuthRequired));
630
631 assert!(InputRequired.can_transition_to(Working));
633 assert!(InputRequired.can_transition_to(Failed));
634 assert!(InputRequired.can_transition_to(Canceled));
635
636 assert!(AuthRequired.can_transition_to(Working));
638 assert!(AuthRequired.can_transition_to(Failed));
639 assert!(AuthRequired.can_transition_to(Canceled));
640 }
641
642 #[test]
644 fn can_transition_to_invalid_transitions() {
645 use TaskState::*;
646
647 for &terminal in &[Completed, Failed, Canceled, Rejected] {
649 for &target in &[
650 Unspecified,
651 Submitted,
652 Working,
653 InputRequired,
654 AuthRequired,
655 Completed,
656 Failed,
657 Canceled,
658 Rejected,
659 ] {
660 assert!(
661 !terminal.can_transition_to(target),
662 "{terminal:?} → {target:?} should be invalid (terminal state)"
663 );
664 }
665 }
666
667 assert!(!Submitted.can_transition_to(Completed));
669 assert!(!Submitted.can_transition_to(InputRequired));
670 assert!(!Submitted.can_transition_to(AuthRequired));
671 assert!(!Submitted.can_transition_to(Submitted));
672 assert!(!Submitted.can_transition_to(Unspecified));
673
674 assert!(Working.can_transition_to(Working));
677
678 assert!(!Working.can_transition_to(Submitted));
680 assert!(!Working.can_transition_to(Unspecified));
681 assert!(!Working.can_transition_to(Rejected));
682
683 assert!(!InputRequired.can_transition_to(Completed));
685 assert!(!InputRequired.can_transition_to(Submitted));
686 assert!(!InputRequired.can_transition_to(InputRequired));
687 assert!(!InputRequired.can_transition_to(AuthRequired));
688 assert!(!InputRequired.can_transition_to(Unspecified));
689 assert!(!InputRequired.can_transition_to(Rejected));
690
691 assert!(!AuthRequired.can_transition_to(Completed));
693 assert!(!AuthRequired.can_transition_to(Submitted));
694 assert!(!AuthRequired.can_transition_to(InputRequired));
695 assert!(!AuthRequired.can_transition_to(AuthRequired));
696 assert!(!AuthRequired.can_transition_to(Unspecified));
697 assert!(!AuthRequired.can_transition_to(Rejected));
698 }
699
700 #[test]
703 fn task_id_display_and_as_ref() {
704 let id = TaskId::new("abc");
705 assert_eq!(id.to_string(), "abc");
706 assert_eq!(id.as_ref(), "abc");
707 }
708
709 #[test]
710 fn task_id_from_impls() {
711 let from_str: TaskId = "hello".into();
712 assert_eq!(from_str, TaskId::new("hello"));
713
714 let from_string: TaskId = String::from("world").into();
715 assert_eq!(from_string, TaskId::new("world"));
716 }
717
718 #[test]
719 fn context_id_display_and_as_ref() {
720 let id = ContextId::new("ctx");
721 assert_eq!(id.to_string(), "ctx");
722 assert_eq!(id.as_ref(), "ctx");
723 }
724
725 #[test]
726 fn context_id_from_impls() {
727 let from_str: ContextId = "c1".into();
728 assert_eq!(from_str, ContextId::new("c1"));
729
730 let from_string: ContextId = String::from("c2").into();
731 assert_eq!(from_string, ContextId::new("c2"));
732 }
733
734 #[test]
735 fn task_version_display() {
736 assert_eq!(TaskVersion::new(42).to_string(), "42");
737 assert_eq!(TaskVersion::new(0).to_string(), "0");
738 }
739
740 #[test]
741 fn task_version_from_u64() {
742 let v: TaskVersion = 99u64.into();
743 assert_eq!(v.get(), 99);
744 }
745
746 #[test]
747 fn task_status_with_timestamp_has_timestamp() {
748 let status = TaskStatus::with_timestamp(TaskState::Working);
749 assert!(
750 status.timestamp.is_some(),
751 "with_timestamp should set timestamp"
752 );
753 assert!(status.message.is_none());
754 assert_eq!(status.state, TaskState::Working);
755 }
756
757 #[test]
758 fn task_status_new_has_no_timestamp() {
759 let status = TaskStatus::new(TaskState::Submitted);
760 assert!(status.timestamp.is_none());
761 assert!(status.message.is_none());
762 assert_eq!(status.state, TaskState::Submitted);
763 }
764
765 #[test]
768 fn task_id_try_new_valid() {
769 let id = TaskId::try_new("task-1");
770 assert!(id.is_ok());
771 assert_eq!(id.unwrap(), TaskId::new("task-1"));
772 }
773
774 #[test]
775 fn task_id_try_new_valid_string() {
776 let id = TaskId::try_new("task-1".to_string());
777 assert!(id.is_ok());
778 assert_eq!(id.unwrap(), TaskId::new("task-1"));
779 }
780
781 #[test]
782 fn task_id_try_new_empty_rejected() {
783 let id = TaskId::try_new("");
784 assert!(id.is_err());
785 assert_eq!(
786 id.unwrap_err(),
787 "TaskId must not be empty or whitespace-only"
788 );
789 }
790
791 #[test]
792 fn task_id_try_new_whitespace_only_rejected() {
793 let id = TaskId::try_new(" ");
794 assert!(id.is_err());
795 }
796
797 #[test]
798 fn context_id_try_new_valid() {
799 let id = ContextId::try_new("ctx-1");
800 assert!(id.is_ok());
801 assert_eq!(id.unwrap(), ContextId::new("ctx-1"));
802 }
803
804 #[test]
805 fn context_id_try_new_valid_string() {
806 let id = ContextId::try_new("ctx-1".to_string());
807 assert!(id.is_ok());
808 assert_eq!(id.unwrap(), ContextId::new("ctx-1"));
809 }
810
811 #[test]
812 fn context_id_try_new_empty_rejected() {
813 let id = ContextId::try_new("");
814 assert!(id.is_err());
815 assert_eq!(
816 id.unwrap_err(),
817 "ContextId must not be empty or whitespace-only"
818 );
819 }
820
821 #[test]
822 fn context_id_try_new_whitespace_only_rejected() {
823 let id = ContextId::try_new(" \t ");
824 assert!(id.is_err());
825 }
826
827 #[test]
830 fn has_valid_timestamp_none_is_valid() {
831 let status = TaskStatus::new(TaskState::Working);
832 assert!(status.has_valid_timestamp());
833 }
834
835 #[test]
836 fn has_valid_timestamp_valid_iso8601() {
837 let status = TaskStatus {
838 state: TaskState::Working,
839 message: None,
840 timestamp: Some("2026-03-19T12:00:00Z".into()),
841 };
842 assert!(status.has_valid_timestamp());
843 }
844
845 #[test]
846 fn has_valid_timestamp_valid_with_offset() {
847 let status = TaskStatus {
848 state: TaskState::Working,
849 message: None,
850 timestamp: Some("2026-03-19T12:00:00+05:30".into()),
851 };
852 assert!(status.has_valid_timestamp());
853 }
854
855 #[test]
856 fn has_valid_timestamp_too_short() {
857 let status = TaskStatus {
858 state: TaskState::Working,
859 message: None,
860 timestamp: Some("2026-03-19".into()),
861 };
862 assert!(!status.has_valid_timestamp());
863 }
864
865 #[test]
866 fn has_valid_timestamp_missing_t_separator() {
867 let status = TaskStatus {
868 state: TaskState::Working,
869 message: None,
870 timestamp: Some("2026-03-19 12:00:00Z".into()),
871 };
872 assert!(!status.has_valid_timestamp());
873 }
874
875 #[test]
876 fn has_valid_timestamp_empty_string() {
877 let status = TaskStatus {
878 state: TaskState::Working,
879 message: None,
880 timestamp: Some(String::new()),
881 };
882 assert!(!status.has_valid_timestamp());
883 }
884
885 #[test]
886 fn has_valid_timestamp_with_timestamp_constructor() {
887 let status = TaskStatus::with_timestamp(TaskState::Completed);
888 assert!(status.has_valid_timestamp());
889 }
890}