Skip to main content

agent_teams/models/
task.rs

1//! Task models compatible with Claude Code's
2//! `~/.claude/tasks/{team-name}/{id}.json` format.
3
4use serde::{Deserialize, Serialize};
5
6/// Task status with forward-only transitions.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum TaskStatus {
10    Pending,
11    InProgress,
12    Completed,
13    Deleted,
14}
15
16impl TaskStatus {
17    /// Check whether transitioning from `self` to `target` is valid.
18    ///
19    /// Allowed transitions:
20    /// - `pending -> in_progress -> completed`
21    /// - Any state -> `deleted`
22    pub fn can_transition_to(self, target: TaskStatus) -> bool {
23        if target == TaskStatus::Deleted {
24            return true;
25        }
26        matches!(
27            (self, target),
28            (TaskStatus::Pending, TaskStatus::InProgress)
29                | (TaskStatus::InProgress, TaskStatus::Completed)
30        )
31    }
32}
33
34impl std::fmt::Display for TaskStatus {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        match self {
37            TaskStatus::Pending => write!(f, "pending"),
38            TaskStatus::InProgress => write!(f, "in_progress"),
39            TaskStatus::Completed => write!(f, "completed"),
40            TaskStatus::Deleted => write!(f, "deleted"),
41        }
42    }
43}
44
45/// A task persisted as `{id}.json` in the tasks directory.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct TaskFile {
49    pub id: String,
50    pub subject: String,
51
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub description: Option<String>,
54
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub active_form: Option<String>,
57
58    pub status: TaskStatus,
59
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub owner: Option<String>,
62
63    /// Task IDs that this task blocks (they cannot start until this completes).
64    #[serde(default, skip_serializing_if = "Vec::is_empty")]
65    pub blocks: Vec<String>,
66
67    /// Task IDs that must complete before this task can start.
68    #[serde(default, skip_serializing_if = "Vec::is_empty")]
69    pub blocked_by: Vec<String>,
70
71    /// Arbitrary metadata.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub metadata: Option<serde_json::Value>,
74}
75
76/// Request to create a new task.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78#[serde(rename_all = "camelCase")]
79pub struct CreateTaskRequest {
80    pub subject: String,
81
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub description: Option<String>,
84
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub active_form: Option<String>,
87
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub metadata: Option<serde_json::Value>,
90}
91
92/// Partial update for an existing task.
93#[derive(Debug, Clone, Default, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase")]
95pub struct TaskUpdate {
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub subject: Option<String>,
98
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub description: Option<String>,
101
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub active_form: Option<String>,
104
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub status: Option<TaskStatus>,
107
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub owner: Option<String>,
110
111    /// Task IDs to add to `blocks`.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub add_blocks: Option<Vec<String>>,
114
115    /// Task IDs to add to `blocked_by`.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub add_blocked_by: Option<Vec<String>>,
118
119    /// Metadata keys to merge (set value to `null` to delete a key).
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub metadata: Option<serde_json::Value>,
122}
123
124/// Filter criteria for listing tasks.
125#[derive(Debug, Clone, Default)]
126pub struct TaskFilter {
127    pub status: Option<TaskStatus>,
128    pub owner: Option<String>,
129    /// If true, only return tasks whose `blocked_by` is empty or all completed.
130    pub unblocked_only: bool,
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn status_transitions() {
139        assert!(TaskStatus::Pending.can_transition_to(TaskStatus::InProgress));
140        assert!(TaskStatus::InProgress.can_transition_to(TaskStatus::Completed));
141        assert!(!TaskStatus::Pending.can_transition_to(TaskStatus::Completed));
142        assert!(!TaskStatus::Completed.can_transition_to(TaskStatus::InProgress));
143
144        // Deleted is always allowed
145        assert!(TaskStatus::Pending.can_transition_to(TaskStatus::Deleted));
146        assert!(TaskStatus::InProgress.can_transition_to(TaskStatus::Deleted));
147        assert!(TaskStatus::Completed.can_transition_to(TaskStatus::Deleted));
148    }
149
150    #[test]
151    fn serde_round_trip_task() {
152        let task = TaskFile {
153            id: "1".into(),
154            subject: "Fix auth bug".into(),
155            description: Some("The login endpoint returns 500".into()),
156            active_form: Some("Fixing auth bug".into()),
157            status: TaskStatus::Pending,
158            owner: None,
159            blocks: vec!["2".into()],
160            blocked_by: vec![],
161            metadata: None,
162        };
163
164        let json = serde_json::to_string_pretty(&task).unwrap();
165        let parsed: TaskFile = serde_json::from_str(&json).unwrap();
166        assert_eq!(parsed.id, "1");
167        assert_eq!(parsed.blocks, vec!["2"]);
168    }
169
170    #[test]
171    fn deserialize_claude_code_format() {
172        let json = r#"{
173            "id": "42",
174            "subject": "Implement feature",
175            "description": "Add caching",
176            "activeForm": "Implementing feature",
177            "status": "in_progress",
178            "owner": "coder",
179            "blocks": [],
180            "blockedBy": ["41"]
181        }"#;
182
183        let task: TaskFile = serde_json::from_str(json).unwrap();
184        assert_eq!(task.id, "42");
185        assert_eq!(task.status, TaskStatus::InProgress);
186        assert_eq!(task.owner.as_deref(), Some("coder"));
187        assert_eq!(task.blocked_by, vec!["41"]);
188    }
189}