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,
274 Self::Completed | Self::Failed | Self::Canceled | Self::InputRequired | Self::AuthRequired)
275 | (Self::InputRequired | Self::AuthRequired,
277 Self::Working | Self::Failed | Self::Canceled)
278 )
279 }
280}
281
282impl std::fmt::Display for TaskState {
283 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284 let s = match self {
285 Self::Unspecified => "TASK_STATE_UNSPECIFIED",
286 Self::Submitted => "TASK_STATE_SUBMITTED",
287 Self::Working => "TASK_STATE_WORKING",
288 Self::InputRequired => "TASK_STATE_INPUT_REQUIRED",
289 Self::AuthRequired => "TASK_STATE_AUTH_REQUIRED",
290 Self::Completed => "TASK_STATE_COMPLETED",
291 Self::Failed => "TASK_STATE_FAILED",
292 Self::Canceled => "TASK_STATE_CANCELED",
293 Self::Rejected => "TASK_STATE_REJECTED",
294 };
295 f.write_str(s)
296 }
297}
298
299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
304#[serde(rename_all = "camelCase")]
305pub struct TaskStatus {
306 pub state: TaskState,
308
309 #[serde(skip_serializing_if = "Option::is_none")]
311 pub message: Option<Message>,
312
313 #[serde(skip_serializing_if = "Option::is_none")]
315 pub timestamp: Option<String>,
316}
317
318impl TaskStatus {
319 #[must_use]
324 pub const fn new(state: TaskState) -> Self {
325 Self {
326 state,
327 message: None,
328 timestamp: None,
329 }
330 }
331
332 #[must_use]
334 pub fn with_timestamp(state: TaskState) -> Self {
335 Self {
336 state,
337 message: None,
338 timestamp: Some(crate::utc_now_iso8601()),
339 }
340 }
341
342 #[must_use]
348 pub fn has_valid_timestamp(&self) -> bool {
349 self.timestamp
352 .as_ref()
353 .is_none_or(|ts| ts.len() >= 19 && ts.contains('T'))
354 }
355}
356
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
367#[serde(rename_all = "camelCase")]
368pub struct Task {
369 pub id: TaskId,
371
372 pub context_id: ContextId,
374
375 pub status: TaskStatus,
377
378 #[serde(skip_serializing_if = "Option::is_none")]
380 pub history: Option<Vec<Message>>,
381
382 #[serde(skip_serializing_if = "Option::is_none")]
384 pub artifacts: Option<Vec<Artifact>>,
385
386 #[serde(skip_serializing_if = "Option::is_none")]
388 pub metadata: Option<serde_json::Value>,
389}
390
391#[cfg(test)]
394mod tests {
395 use super::*;
396
397 fn make_task() -> Task {
398 Task {
399 id: TaskId::new("task-1"),
400 context_id: ContextId::new("ctx-1"),
401 status: TaskStatus::new(TaskState::Working),
402 history: None,
403 artifacts: None,
404 metadata: None,
405 }
406 }
407
408 #[test]
409 fn task_state_screaming_snake_serde() {
410 assert_eq!(
411 serde_json::to_string(&TaskState::InputRequired).expect("ser"),
412 "\"TASK_STATE_INPUT_REQUIRED\""
413 );
414 assert_eq!(
415 serde_json::to_string(&TaskState::AuthRequired).expect("ser"),
416 "\"TASK_STATE_AUTH_REQUIRED\""
417 );
418 assert_eq!(
419 serde_json::to_string(&TaskState::Submitted).expect("ser"),
420 "\"TASK_STATE_SUBMITTED\""
421 );
422 assert_eq!(
423 serde_json::to_string(&TaskState::Unspecified).expect("ser"),
424 "\"TASK_STATE_UNSPECIFIED\""
425 );
426 let back: TaskState = serde_json::from_str("\"completed\"").unwrap();
428 assert_eq!(back, TaskState::Completed);
429 let back: TaskState = serde_json::from_str("\"input-required\"").unwrap();
430 assert_eq!(back, TaskState::InputRequired);
431 }
432
433 #[test]
434 fn task_state_is_terminal() {
435 assert!(TaskState::Completed.is_terminal());
436 assert!(TaskState::Failed.is_terminal());
437 assert!(TaskState::Canceled.is_terminal());
438 assert!(TaskState::Rejected.is_terminal());
439 assert!(!TaskState::Working.is_terminal());
440 assert!(!TaskState::Submitted.is_terminal());
441 }
442
443 #[test]
444 fn task_roundtrip() {
445 let task = make_task();
446 let json = serde_json::to_string(&task).expect("serialize");
447 assert!(json.contains("\"id\":\"task-1\""));
448
449 let back: Task = serde_json::from_str(&json).expect("deserialize");
450 assert_eq!(back.id, TaskId::new("task-1"));
451 assert_eq!(back.context_id, ContextId::new("ctx-1"));
452 assert_eq!(back.status.state, TaskState::Working);
453 }
454
455 #[test]
456 fn optional_fields_omitted() {
457 let task = make_task();
458 let json = serde_json::to_string(&task).expect("serialize");
459 assert!(!json.contains("\"history\""), "history should be omitted");
460 assert!(
461 !json.contains("\"artifacts\""),
462 "artifacts should be omitted"
463 );
464 assert!(!json.contains("\"metadata\""), "metadata should be omitted");
465 }
466
467 #[test]
468 fn task_version_ordering() {
469 assert!(TaskVersion::new(2) > TaskVersion::new(1));
470 assert_eq!(TaskVersion::new(5).get(), 5);
471 }
472
473 #[test]
474 fn wire_format_submitted_state() {
475 let json = serde_json::to_string(&TaskState::Submitted).unwrap();
476 assert_eq!(json, "\"TASK_STATE_SUBMITTED\"");
477
478 let back: TaskState = serde_json::from_str("\"submitted\"").unwrap();
480 assert_eq!(back, TaskState::Submitted);
481 let back: TaskState = serde_json::from_str("\"TASK_STATE_SUBMITTED\"").unwrap();
482 assert_eq!(back, TaskState::Submitted);
483 }
484
485 #[test]
486 fn task_version_serde_roundtrip() {
487 let v = TaskVersion::new(42);
488 let json = serde_json::to_string(&v).expect("serialize");
489 assert_eq!(json, "42");
490
491 let back: TaskVersion = serde_json::from_str(&json).expect("deserialize");
492 assert_eq!(back, TaskVersion::new(42));
493
494 let v0 = TaskVersion::new(0);
496 let json0 = serde_json::to_string(&v0).expect("serialize zero");
497 assert_eq!(json0, "0");
498 let back0: TaskVersion = serde_json::from_str(&json0).expect("deserialize zero");
499 assert_eq!(back0, TaskVersion::new(0));
500
501 let vmax = TaskVersion::new(u64::MAX);
503 let json_max = serde_json::to_string(&vmax).expect("serialize max");
504 let back_max: TaskVersion = serde_json::from_str(&json_max).expect("deserialize max");
505 assert_eq!(back_max, vmax);
506 }
507
508 #[test]
509 fn empty_string_ids_work() {
510 let tid = TaskId::new("");
511 let json = serde_json::to_string(&tid).expect("serialize empty TaskId");
512 assert_eq!(json, "\"\"");
513 let back: TaskId = serde_json::from_str(&json).expect("deserialize empty TaskId");
514 assert_eq!(back, TaskId::new(""));
515
516 let cid = ContextId::new("");
517 let json = serde_json::to_string(&cid).expect("serialize empty ContextId");
518 assert_eq!(json, "\"\"");
519 let back: ContextId = serde_json::from_str(&json).expect("deserialize empty ContextId");
520 assert_eq!(back, ContextId::new(""));
521
522 let task = Task {
524 id: TaskId::new(""),
525 context_id: ContextId::new(""),
526 status: TaskStatus::new(TaskState::Submitted),
527 history: None,
528 artifacts: None,
529 metadata: None,
530 };
531 let json = serde_json::to_string(&task).expect("serialize task with empty ids");
532 let back: Task = serde_json::from_str(&json).expect("deserialize task with empty ids");
533 assert_eq!(back.id, TaskId::new(""));
534 assert_eq!(back.context_id, ContextId::new(""));
535 }
536
537 #[test]
538 fn task_state_display_trait() {
539 assert_eq!(TaskState::Working.to_string(), "TASK_STATE_WORKING");
540 assert_eq!(TaskState::Completed.to_string(), "TASK_STATE_COMPLETED");
541 assert_eq!(TaskState::Failed.to_string(), "TASK_STATE_FAILED");
542 assert_eq!(TaskState::Canceled.to_string(), "TASK_STATE_CANCELED");
543 assert_eq!(TaskState::Rejected.to_string(), "TASK_STATE_REJECTED");
544 assert_eq!(TaskState::Submitted.to_string(), "TASK_STATE_SUBMITTED");
545 assert_eq!(
546 TaskState::InputRequired.to_string(),
547 "TASK_STATE_INPUT_REQUIRED"
548 );
549 assert_eq!(
550 TaskState::AuthRequired.to_string(),
551 "TASK_STATE_AUTH_REQUIRED"
552 );
553 assert_eq!(TaskState::Unspecified.to_string(), "TASK_STATE_UNSPECIFIED");
554 }
555
556 #[test]
559 fn is_terminal_all_variants() {
560 assert!(!TaskState::Unspecified.is_terminal());
561 assert!(!TaskState::Submitted.is_terminal());
562 assert!(!TaskState::Working.is_terminal());
563 assert!(!TaskState::InputRequired.is_terminal());
564 assert!(!TaskState::AuthRequired.is_terminal());
565 assert!(TaskState::Completed.is_terminal());
566 assert!(TaskState::Failed.is_terminal());
567 assert!(TaskState::Canceled.is_terminal());
568 assert!(TaskState::Rejected.is_terminal());
569 }
570
571 #[test]
575 fn can_transition_to_valid_transitions() {
576 use TaskState::*;
577
578 for &target in &[
580 Unspecified,
581 Submitted,
582 Working,
583 InputRequired,
584 AuthRequired,
585 Completed,
586 Failed,
587 Canceled,
588 Rejected,
589 ] {
590 assert!(
591 Unspecified.can_transition_to(target),
592 "Unspecified → {target:?} should be valid"
593 );
594 }
595
596 assert!(Submitted.can_transition_to(Working));
598 assert!(Submitted.can_transition_to(Failed));
599 assert!(Submitted.can_transition_to(Canceled));
600 assert!(Submitted.can_transition_to(Rejected));
601
602 assert!(Working.can_transition_to(Completed));
604 assert!(Working.can_transition_to(Failed));
605 assert!(Working.can_transition_to(Canceled));
606 assert!(Working.can_transition_to(InputRequired));
607 assert!(Working.can_transition_to(AuthRequired));
608
609 assert!(InputRequired.can_transition_to(Working));
611 assert!(InputRequired.can_transition_to(Failed));
612 assert!(InputRequired.can_transition_to(Canceled));
613
614 assert!(AuthRequired.can_transition_to(Working));
616 assert!(AuthRequired.can_transition_to(Failed));
617 assert!(AuthRequired.can_transition_to(Canceled));
618 }
619
620 #[test]
622 fn can_transition_to_invalid_transitions() {
623 use TaskState::*;
624
625 for &terminal in &[Completed, Failed, Canceled, Rejected] {
627 for &target in &[
628 Unspecified,
629 Submitted,
630 Working,
631 InputRequired,
632 AuthRequired,
633 Completed,
634 Failed,
635 Canceled,
636 Rejected,
637 ] {
638 assert!(
639 !terminal.can_transition_to(target),
640 "{terminal:?} → {target:?} should be invalid (terminal state)"
641 );
642 }
643 }
644
645 assert!(!Submitted.can_transition_to(Completed));
647 assert!(!Submitted.can_transition_to(InputRequired));
648 assert!(!Submitted.can_transition_to(AuthRequired));
649 assert!(!Submitted.can_transition_to(Submitted));
650 assert!(!Submitted.can_transition_to(Unspecified));
651
652 assert!(!Working.can_transition_to(Submitted));
654 assert!(!Working.can_transition_to(Working));
655 assert!(!Working.can_transition_to(Unspecified));
656 assert!(!Working.can_transition_to(Rejected));
657
658 assert!(!InputRequired.can_transition_to(Completed));
660 assert!(!InputRequired.can_transition_to(Submitted));
661 assert!(!InputRequired.can_transition_to(InputRequired));
662 assert!(!InputRequired.can_transition_to(AuthRequired));
663 assert!(!InputRequired.can_transition_to(Unspecified));
664 assert!(!InputRequired.can_transition_to(Rejected));
665
666 assert!(!AuthRequired.can_transition_to(Completed));
668 assert!(!AuthRequired.can_transition_to(Submitted));
669 assert!(!AuthRequired.can_transition_to(InputRequired));
670 assert!(!AuthRequired.can_transition_to(AuthRequired));
671 assert!(!AuthRequired.can_transition_to(Unspecified));
672 assert!(!AuthRequired.can_transition_to(Rejected));
673 }
674
675 #[test]
678 fn task_id_display_and_as_ref() {
679 let id = TaskId::new("abc");
680 assert_eq!(id.to_string(), "abc");
681 assert_eq!(id.as_ref(), "abc");
682 }
683
684 #[test]
685 fn task_id_from_impls() {
686 let from_str: TaskId = "hello".into();
687 assert_eq!(from_str, TaskId::new("hello"));
688
689 let from_string: TaskId = String::from("world").into();
690 assert_eq!(from_string, TaskId::new("world"));
691 }
692
693 #[test]
694 fn context_id_display_and_as_ref() {
695 let id = ContextId::new("ctx");
696 assert_eq!(id.to_string(), "ctx");
697 assert_eq!(id.as_ref(), "ctx");
698 }
699
700 #[test]
701 fn context_id_from_impls() {
702 let from_str: ContextId = "c1".into();
703 assert_eq!(from_str, ContextId::new("c1"));
704
705 let from_string: ContextId = String::from("c2").into();
706 assert_eq!(from_string, ContextId::new("c2"));
707 }
708
709 #[test]
710 fn task_version_display() {
711 assert_eq!(TaskVersion::new(42).to_string(), "42");
712 assert_eq!(TaskVersion::new(0).to_string(), "0");
713 }
714
715 #[test]
716 fn task_version_from_u64() {
717 let v: TaskVersion = 99u64.into();
718 assert_eq!(v.get(), 99);
719 }
720
721 #[test]
722 fn task_status_with_timestamp_has_timestamp() {
723 let status = TaskStatus::with_timestamp(TaskState::Working);
724 assert!(
725 status.timestamp.is_some(),
726 "with_timestamp should set timestamp"
727 );
728 assert!(status.message.is_none());
729 assert_eq!(status.state, TaskState::Working);
730 }
731
732 #[test]
733 fn task_status_new_has_no_timestamp() {
734 let status = TaskStatus::new(TaskState::Submitted);
735 assert!(status.timestamp.is_none());
736 assert!(status.message.is_none());
737 assert_eq!(status.state, TaskState::Submitted);
738 }
739
740 #[test]
743 fn task_id_try_new_valid() {
744 let id = TaskId::try_new("task-1");
745 assert!(id.is_ok());
746 assert_eq!(id.unwrap(), TaskId::new("task-1"));
747 }
748
749 #[test]
750 fn task_id_try_new_valid_string() {
751 let id = TaskId::try_new("task-1".to_string());
752 assert!(id.is_ok());
753 assert_eq!(id.unwrap(), TaskId::new("task-1"));
754 }
755
756 #[test]
757 fn task_id_try_new_empty_rejected() {
758 let id = TaskId::try_new("");
759 assert!(id.is_err());
760 assert_eq!(
761 id.unwrap_err(),
762 "TaskId must not be empty or whitespace-only"
763 );
764 }
765
766 #[test]
767 fn task_id_try_new_whitespace_only_rejected() {
768 let id = TaskId::try_new(" ");
769 assert!(id.is_err());
770 }
771
772 #[test]
773 fn context_id_try_new_valid() {
774 let id = ContextId::try_new("ctx-1");
775 assert!(id.is_ok());
776 assert_eq!(id.unwrap(), ContextId::new("ctx-1"));
777 }
778
779 #[test]
780 fn context_id_try_new_valid_string() {
781 let id = ContextId::try_new("ctx-1".to_string());
782 assert!(id.is_ok());
783 assert_eq!(id.unwrap(), ContextId::new("ctx-1"));
784 }
785
786 #[test]
787 fn context_id_try_new_empty_rejected() {
788 let id = ContextId::try_new("");
789 assert!(id.is_err());
790 assert_eq!(
791 id.unwrap_err(),
792 "ContextId must not be empty or whitespace-only"
793 );
794 }
795
796 #[test]
797 fn context_id_try_new_whitespace_only_rejected() {
798 let id = ContextId::try_new(" \t ");
799 assert!(id.is_err());
800 }
801
802 #[test]
805 fn has_valid_timestamp_none_is_valid() {
806 let status = TaskStatus::new(TaskState::Working);
807 assert!(status.has_valid_timestamp());
808 }
809
810 #[test]
811 fn has_valid_timestamp_valid_iso8601() {
812 let status = TaskStatus {
813 state: TaskState::Working,
814 message: None,
815 timestamp: Some("2026-03-19T12:00:00Z".into()),
816 };
817 assert!(status.has_valid_timestamp());
818 }
819
820 #[test]
821 fn has_valid_timestamp_valid_with_offset() {
822 let status = TaskStatus {
823 state: TaskState::Working,
824 message: None,
825 timestamp: Some("2026-03-19T12:00:00+05:30".into()),
826 };
827 assert!(status.has_valid_timestamp());
828 }
829
830 #[test]
831 fn has_valid_timestamp_too_short() {
832 let status = TaskStatus {
833 state: TaskState::Working,
834 message: None,
835 timestamp: Some("2026-03-19".into()),
836 };
837 assert!(!status.has_valid_timestamp());
838 }
839
840 #[test]
841 fn has_valid_timestamp_missing_t_separator() {
842 let status = TaskStatus {
843 state: TaskState::Working,
844 message: None,
845 timestamp: Some("2026-03-19 12:00:00Z".into()),
846 };
847 assert!(!status.has_valid_timestamp());
848 }
849
850 #[test]
851 fn has_valid_timestamp_empty_string() {
852 let status = TaskStatus {
853 state: TaskState::Working,
854 message: None,
855 timestamp: Some(String::new()),
856 };
857 assert!(!status.has_valid_timestamp());
858 }
859
860 #[test]
861 fn has_valid_timestamp_with_timestamp_constructor() {
862 let status = TaskStatus::with_timestamp(TaskState::Completed);
863 assert!(status.has_valid_timestamp());
864 }
865}