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 = "unspecified", alias = "TASK_STATE_UNSPECIFIED")]
201 Unspecified,
202 #[serde(rename = "submitted", alias = "TASK_STATE_SUBMITTED")]
204 Submitted,
205 #[serde(rename = "working", alias = "TASK_STATE_WORKING")]
207 Working,
208 #[serde(rename = "input-required", alias = "TASK_STATE_INPUT_REQUIRED")]
210 InputRequired,
211 #[serde(rename = "auth-required", alias = "TASK_STATE_AUTH_REQUIRED")]
213 AuthRequired,
214 #[serde(rename = "completed", alias = "TASK_STATE_COMPLETED")]
216 Completed,
217 #[serde(rename = "failed", alias = "TASK_STATE_FAILED")]
219 Failed,
220 #[serde(rename = "canceled", alias = "TASK_STATE_CANCELED")]
222 Canceled,
223 #[serde(rename = "rejected", alias = "TASK_STATE_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 can_transition_to(self, next: Self) -> bool {
249 if self.is_terminal() {
251 return false;
252 }
253 if matches!(self, Self::Unspecified) {
255 return true;
256 }
257 matches!(
258 (self, next),
259 (Self::Submitted, Self::Working | Self::Failed | Self::Canceled | Self::Rejected)
261 | (Self::Working,
263 Self::Completed | Self::Failed | Self::Canceled | Self::InputRequired | Self::AuthRequired)
264 | (Self::InputRequired | Self::AuthRequired,
266 Self::Working | Self::Failed | Self::Canceled)
267 )
268 }
269}
270
271impl std::fmt::Display for TaskState {
272 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
273 let s = match self {
274 Self::Unspecified => "unspecified",
275 Self::Submitted => "submitted",
276 Self::Working => "working",
277 Self::InputRequired => "input-required",
278 Self::AuthRequired => "auth-required",
279 Self::Completed => "completed",
280 Self::Failed => "failed",
281 Self::Canceled => "canceled",
282 Self::Rejected => "rejected",
283 };
284 f.write_str(s)
285 }
286}
287
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(rename_all = "camelCase")]
294pub struct TaskStatus {
295 pub state: TaskState,
297
298 #[serde(skip_serializing_if = "Option::is_none")]
300 pub message: Option<Message>,
301
302 #[serde(skip_serializing_if = "Option::is_none")]
304 pub timestamp: Option<String>,
305}
306
307impl TaskStatus {
308 #[must_use]
313 pub const fn new(state: TaskState) -> Self {
314 Self {
315 state,
316 message: None,
317 timestamp: None,
318 }
319 }
320
321 #[must_use]
323 pub fn with_timestamp(state: TaskState) -> Self {
324 Self {
325 state,
326 message: None,
327 timestamp: Some(crate::utc_now_iso8601()),
328 }
329 }
330
331 #[must_use]
337 pub fn has_valid_timestamp(&self) -> bool {
338 self.timestamp
341 .as_ref()
342 .is_none_or(|ts| ts.len() >= 19 && ts.contains('T'))
343 }
344}
345
346#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
356#[serde(rename_all = "camelCase")]
357pub struct Task {
358 pub id: TaskId,
360
361 pub context_id: ContextId,
363
364 pub status: TaskStatus,
366
367 #[serde(skip_serializing_if = "Option::is_none")]
369 pub history: Option<Vec<Message>>,
370
371 #[serde(skip_serializing_if = "Option::is_none")]
373 pub artifacts: Option<Vec<Artifact>>,
374
375 #[serde(skip_serializing_if = "Option::is_none")]
377 pub metadata: Option<serde_json::Value>,
378}
379
380#[cfg(test)]
383mod tests {
384 use super::*;
385
386 fn make_task() -> Task {
387 Task {
388 id: TaskId::new("task-1"),
389 context_id: ContextId::new("ctx-1"),
390 status: TaskStatus::new(TaskState::Working),
391 history: None,
392 artifacts: None,
393 metadata: None,
394 }
395 }
396
397 #[test]
398 fn task_state_lowercase_serde() {
399 assert_eq!(
400 serde_json::to_string(&TaskState::InputRequired).expect("ser"),
401 "\"input-required\""
402 );
403 assert_eq!(
404 serde_json::to_string(&TaskState::AuthRequired).expect("ser"),
405 "\"auth-required\""
406 );
407 assert_eq!(
408 serde_json::to_string(&TaskState::Submitted).expect("ser"),
409 "\"submitted\""
410 );
411 assert_eq!(
412 serde_json::to_string(&TaskState::Unspecified).expect("ser"),
413 "\"unspecified\""
414 );
415 let back: TaskState = serde_json::from_str("\"TASK_STATE_COMPLETED\"").unwrap();
417 assert_eq!(back, TaskState::Completed);
418 }
419
420 #[test]
421 fn task_state_is_terminal() {
422 assert!(TaskState::Completed.is_terminal());
423 assert!(TaskState::Failed.is_terminal());
424 assert!(TaskState::Canceled.is_terminal());
425 assert!(TaskState::Rejected.is_terminal());
426 assert!(!TaskState::Working.is_terminal());
427 assert!(!TaskState::Submitted.is_terminal());
428 }
429
430 #[test]
431 fn task_roundtrip() {
432 let task = make_task();
433 let json = serde_json::to_string(&task).expect("serialize");
434 assert!(json.contains("\"id\":\"task-1\""));
435
436 let back: Task = serde_json::from_str(&json).expect("deserialize");
437 assert_eq!(back.id, TaskId::new("task-1"));
438 assert_eq!(back.context_id, ContextId::new("ctx-1"));
439 assert_eq!(back.status.state, TaskState::Working);
440 }
441
442 #[test]
443 fn optional_fields_omitted() {
444 let task = make_task();
445 let json = serde_json::to_string(&task).expect("serialize");
446 assert!(!json.contains("\"history\""), "history should be omitted");
447 assert!(
448 !json.contains("\"artifacts\""),
449 "artifacts should be omitted"
450 );
451 assert!(!json.contains("\"metadata\""), "metadata should be omitted");
452 }
453
454 #[test]
455 fn task_version_ordering() {
456 assert!(TaskVersion::new(2) > TaskVersion::new(1));
457 assert_eq!(TaskVersion::new(5).get(), 5);
458 }
459
460 #[test]
461 fn wire_format_submitted_state() {
462 let json = serde_json::to_string(&TaskState::Submitted).unwrap();
463 assert_eq!(json, "\"submitted\"");
464
465 let back: TaskState = serde_json::from_str("\"submitted\"").unwrap();
467 assert_eq!(back, TaskState::Submitted);
468 let back: TaskState = serde_json::from_str("\"TASK_STATE_SUBMITTED\"").unwrap();
469 assert_eq!(back, TaskState::Submitted);
470 }
471
472 #[test]
473 fn task_version_serde_roundtrip() {
474 let v = TaskVersion::new(42);
475 let json = serde_json::to_string(&v).expect("serialize");
476 assert_eq!(json, "42");
477
478 let back: TaskVersion = serde_json::from_str(&json).expect("deserialize");
479 assert_eq!(back, TaskVersion::new(42));
480
481 let v0 = TaskVersion::new(0);
483 let json0 = serde_json::to_string(&v0).expect("serialize zero");
484 assert_eq!(json0, "0");
485 let back0: TaskVersion = serde_json::from_str(&json0).expect("deserialize zero");
486 assert_eq!(back0, TaskVersion::new(0));
487
488 let vmax = TaskVersion::new(u64::MAX);
490 let json_max = serde_json::to_string(&vmax).expect("serialize max");
491 let back_max: TaskVersion = serde_json::from_str(&json_max).expect("deserialize max");
492 assert_eq!(back_max, vmax);
493 }
494
495 #[test]
496 fn empty_string_ids_work() {
497 let tid = TaskId::new("");
498 let json = serde_json::to_string(&tid).expect("serialize empty TaskId");
499 assert_eq!(json, "\"\"");
500 let back: TaskId = serde_json::from_str(&json).expect("deserialize empty TaskId");
501 assert_eq!(back, TaskId::new(""));
502
503 let cid = ContextId::new("");
504 let json = serde_json::to_string(&cid).expect("serialize empty ContextId");
505 assert_eq!(json, "\"\"");
506 let back: ContextId = serde_json::from_str(&json).expect("deserialize empty ContextId");
507 assert_eq!(back, ContextId::new(""));
508
509 let task = Task {
511 id: TaskId::new(""),
512 context_id: ContextId::new(""),
513 status: TaskStatus::new(TaskState::Submitted),
514 history: None,
515 artifacts: None,
516 metadata: None,
517 };
518 let json = serde_json::to_string(&task).expect("serialize task with empty ids");
519 let back: Task = serde_json::from_str(&json).expect("deserialize task with empty ids");
520 assert_eq!(back.id, TaskId::new(""));
521 assert_eq!(back.context_id, ContextId::new(""));
522 }
523
524 #[test]
525 fn task_state_display_trait() {
526 assert_eq!(TaskState::Working.to_string(), "working");
527 assert_eq!(TaskState::Completed.to_string(), "completed");
528 assert_eq!(TaskState::Failed.to_string(), "failed");
529 assert_eq!(TaskState::Canceled.to_string(), "canceled");
530 assert_eq!(TaskState::Rejected.to_string(), "rejected");
531 assert_eq!(TaskState::Submitted.to_string(), "submitted");
532 assert_eq!(TaskState::InputRequired.to_string(), "input-required");
533 assert_eq!(TaskState::AuthRequired.to_string(), "auth-required");
534 assert_eq!(TaskState::Unspecified.to_string(), "unspecified");
535 }
536
537 #[test]
540 fn is_terminal_all_variants() {
541 assert!(!TaskState::Unspecified.is_terminal());
542 assert!(!TaskState::Submitted.is_terminal());
543 assert!(!TaskState::Working.is_terminal());
544 assert!(!TaskState::InputRequired.is_terminal());
545 assert!(!TaskState::AuthRequired.is_terminal());
546 assert!(TaskState::Completed.is_terminal());
547 assert!(TaskState::Failed.is_terminal());
548 assert!(TaskState::Canceled.is_terminal());
549 assert!(TaskState::Rejected.is_terminal());
550 }
551
552 #[test]
556 fn can_transition_to_valid_transitions() {
557 use TaskState::*;
558
559 for &target in &[
561 Unspecified,
562 Submitted,
563 Working,
564 InputRequired,
565 AuthRequired,
566 Completed,
567 Failed,
568 Canceled,
569 Rejected,
570 ] {
571 assert!(
572 Unspecified.can_transition_to(target),
573 "Unspecified → {target:?} should be valid"
574 );
575 }
576
577 assert!(Submitted.can_transition_to(Working));
579 assert!(Submitted.can_transition_to(Failed));
580 assert!(Submitted.can_transition_to(Canceled));
581 assert!(Submitted.can_transition_to(Rejected));
582
583 assert!(Working.can_transition_to(Completed));
585 assert!(Working.can_transition_to(Failed));
586 assert!(Working.can_transition_to(Canceled));
587 assert!(Working.can_transition_to(InputRequired));
588 assert!(Working.can_transition_to(AuthRequired));
589
590 assert!(InputRequired.can_transition_to(Working));
592 assert!(InputRequired.can_transition_to(Failed));
593 assert!(InputRequired.can_transition_to(Canceled));
594
595 assert!(AuthRequired.can_transition_to(Working));
597 assert!(AuthRequired.can_transition_to(Failed));
598 assert!(AuthRequired.can_transition_to(Canceled));
599 }
600
601 #[test]
603 fn can_transition_to_invalid_transitions() {
604 use TaskState::*;
605
606 for &terminal in &[Completed, Failed, Canceled, Rejected] {
608 for &target in &[
609 Unspecified,
610 Submitted,
611 Working,
612 InputRequired,
613 AuthRequired,
614 Completed,
615 Failed,
616 Canceled,
617 Rejected,
618 ] {
619 assert!(
620 !terminal.can_transition_to(target),
621 "{terminal:?} → {target:?} should be invalid (terminal state)"
622 );
623 }
624 }
625
626 assert!(!Submitted.can_transition_to(Completed));
628 assert!(!Submitted.can_transition_to(InputRequired));
629 assert!(!Submitted.can_transition_to(AuthRequired));
630 assert!(!Submitted.can_transition_to(Submitted));
631 assert!(!Submitted.can_transition_to(Unspecified));
632
633 assert!(!Working.can_transition_to(Submitted));
635 assert!(!Working.can_transition_to(Working));
636 assert!(!Working.can_transition_to(Unspecified));
637 assert!(!Working.can_transition_to(Rejected));
638
639 assert!(!InputRequired.can_transition_to(Completed));
641 assert!(!InputRequired.can_transition_to(Submitted));
642 assert!(!InputRequired.can_transition_to(InputRequired));
643 assert!(!InputRequired.can_transition_to(AuthRequired));
644 assert!(!InputRequired.can_transition_to(Unspecified));
645 assert!(!InputRequired.can_transition_to(Rejected));
646
647 assert!(!AuthRequired.can_transition_to(Completed));
649 assert!(!AuthRequired.can_transition_to(Submitted));
650 assert!(!AuthRequired.can_transition_to(InputRequired));
651 assert!(!AuthRequired.can_transition_to(AuthRequired));
652 assert!(!AuthRequired.can_transition_to(Unspecified));
653 assert!(!AuthRequired.can_transition_to(Rejected));
654 }
655
656 #[test]
659 fn task_id_display_and_as_ref() {
660 let id = TaskId::new("abc");
661 assert_eq!(id.to_string(), "abc");
662 assert_eq!(id.as_ref(), "abc");
663 }
664
665 #[test]
666 fn task_id_from_impls() {
667 let from_str: TaskId = "hello".into();
668 assert_eq!(from_str, TaskId::new("hello"));
669
670 let from_string: TaskId = String::from("world").into();
671 assert_eq!(from_string, TaskId::new("world"));
672 }
673
674 #[test]
675 fn context_id_display_and_as_ref() {
676 let id = ContextId::new("ctx");
677 assert_eq!(id.to_string(), "ctx");
678 assert_eq!(id.as_ref(), "ctx");
679 }
680
681 #[test]
682 fn context_id_from_impls() {
683 let from_str: ContextId = "c1".into();
684 assert_eq!(from_str, ContextId::new("c1"));
685
686 let from_string: ContextId = String::from("c2").into();
687 assert_eq!(from_string, ContextId::new("c2"));
688 }
689
690 #[test]
691 fn task_version_display() {
692 assert_eq!(TaskVersion::new(42).to_string(), "42");
693 assert_eq!(TaskVersion::new(0).to_string(), "0");
694 }
695
696 #[test]
697 fn task_version_from_u64() {
698 let v: TaskVersion = 99u64.into();
699 assert_eq!(v.get(), 99);
700 }
701
702 #[test]
703 fn task_status_with_timestamp_has_timestamp() {
704 let status = TaskStatus::with_timestamp(TaskState::Working);
705 assert!(
706 status.timestamp.is_some(),
707 "with_timestamp should set timestamp"
708 );
709 assert!(status.message.is_none());
710 assert_eq!(status.state, TaskState::Working);
711 }
712
713 #[test]
714 fn task_status_new_has_no_timestamp() {
715 let status = TaskStatus::new(TaskState::Submitted);
716 assert!(status.timestamp.is_none());
717 assert!(status.message.is_none());
718 assert_eq!(status.state, TaskState::Submitted);
719 }
720
721 #[test]
724 fn task_id_try_new_valid() {
725 let id = TaskId::try_new("task-1");
726 assert!(id.is_ok());
727 assert_eq!(id.unwrap(), TaskId::new("task-1"));
728 }
729
730 #[test]
731 fn task_id_try_new_valid_string() {
732 let id = TaskId::try_new("task-1".to_string());
733 assert!(id.is_ok());
734 assert_eq!(id.unwrap(), TaskId::new("task-1"));
735 }
736
737 #[test]
738 fn task_id_try_new_empty_rejected() {
739 let id = TaskId::try_new("");
740 assert!(id.is_err());
741 assert_eq!(
742 id.unwrap_err(),
743 "TaskId must not be empty or whitespace-only"
744 );
745 }
746
747 #[test]
748 fn task_id_try_new_whitespace_only_rejected() {
749 let id = TaskId::try_new(" ");
750 assert!(id.is_err());
751 }
752
753 #[test]
754 fn context_id_try_new_valid() {
755 let id = ContextId::try_new("ctx-1");
756 assert!(id.is_ok());
757 assert_eq!(id.unwrap(), ContextId::new("ctx-1"));
758 }
759
760 #[test]
761 fn context_id_try_new_valid_string() {
762 let id = ContextId::try_new("ctx-1".to_string());
763 assert!(id.is_ok());
764 assert_eq!(id.unwrap(), ContextId::new("ctx-1"));
765 }
766
767 #[test]
768 fn context_id_try_new_empty_rejected() {
769 let id = ContextId::try_new("");
770 assert!(id.is_err());
771 assert_eq!(
772 id.unwrap_err(),
773 "ContextId must not be empty or whitespace-only"
774 );
775 }
776
777 #[test]
778 fn context_id_try_new_whitespace_only_rejected() {
779 let id = ContextId::try_new(" \t ");
780 assert!(id.is_err());
781 }
782
783 #[test]
786 fn has_valid_timestamp_none_is_valid() {
787 let status = TaskStatus::new(TaskState::Working);
788 assert!(status.has_valid_timestamp());
789 }
790
791 #[test]
792 fn has_valid_timestamp_valid_iso8601() {
793 let status = TaskStatus {
794 state: TaskState::Working,
795 message: None,
796 timestamp: Some("2026-03-19T12:00:00Z".into()),
797 };
798 assert!(status.has_valid_timestamp());
799 }
800
801 #[test]
802 fn has_valid_timestamp_valid_with_offset() {
803 let status = TaskStatus {
804 state: TaskState::Working,
805 message: None,
806 timestamp: Some("2026-03-19T12:00:00+05:30".into()),
807 };
808 assert!(status.has_valid_timestamp());
809 }
810
811 #[test]
812 fn has_valid_timestamp_too_short() {
813 let status = TaskStatus {
814 state: TaskState::Working,
815 message: None,
816 timestamp: Some("2026-03-19".into()),
817 };
818 assert!(!status.has_valid_timestamp());
819 }
820
821 #[test]
822 fn has_valid_timestamp_missing_t_separator() {
823 let status = TaskStatus {
824 state: TaskState::Working,
825 message: None,
826 timestamp: Some("2026-03-19 12:00:00Z".into()),
827 };
828 assert!(!status.has_valid_timestamp());
829 }
830
831 #[test]
832 fn has_valid_timestamp_empty_string() {
833 let status = TaskStatus {
834 state: TaskState::Working,
835 message: None,
836 timestamp: Some(String::new()),
837 };
838 assert!(!status.has_valid_timestamp());
839 }
840
841 #[test]
842 fn has_valid_timestamp_with_timestamp_constructor() {
843 let status = TaskStatus::with_timestamp(TaskState::Completed);
844 assert!(status.has_valid_timestamp());
845 }
846}