Skip to main content

starweaver_context/
task.rs

1//! Agent-managed task records and typed task manager.
2
3use std::collections::BTreeMap;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use starweaver_core::Metadata;
9
10/// Task execution status.
11#[derive(Clone, Debug, Default, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
12#[serde(rename_all = "snake_case")]
13pub enum TaskStatus {
14    /// Task is pending.
15    #[default]
16    Pending,
17    /// Task is currently in progress.
18    InProgress,
19    /// Task is completed.
20    Completed,
21}
22
23impl TaskStatus {
24    /// Parse a strict task status string.
25    #[must_use]
26    pub fn parse(value: &str) -> Option<Self> {
27        match value.trim() {
28            "pending" => Some(Self::Pending),
29            "in_progress" => Some(Self::InProgress),
30            "completed" => Some(Self::Completed),
31            _ => None,
32        }
33    }
34
35    /// Return normalized string representation.
36    #[must_use]
37    pub const fn as_str(&self) -> &str {
38        match self {
39            Self::Pending => "pending",
40            Self::InProgress => "in_progress",
41            Self::Completed => "completed",
42        }
43    }
44
45    /// Return whether this status is completed.
46    #[must_use]
47    pub const fn is_completed(&self) -> bool {
48        matches!(self, Self::Completed)
49    }
50}
51
52impl std::fmt::Display for TaskStatus {
53    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        formatter.write_str(self.as_str())
55    }
56}
57
58/// Agent-managed task record used by task tools and display snapshots.
59#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
60pub struct Task {
61    /// Task identifier.
62    pub id: String,
63    /// Task title in imperative form.
64    pub subject: String,
65    /// Detailed task description.
66    pub description: String,
67    /// Current task status: `pending`, `in_progress`, or `completed`.
68    #[serde(default)]
69    pub status: TaskStatus,
70    /// Present-progress label shown while in progress.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub active_form: Option<String>,
73    /// Optional task owner.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub owner: Option<String>,
76    /// Task ids this task is blocked by.
77    #[serde(default, skip_serializing_if = "Vec::is_empty")]
78    pub blocked_by: Vec<String>,
79    /// Task ids this task blocks.
80    #[serde(default, skip_serializing_if = "Vec::is_empty")]
81    pub blocks: Vec<String>,
82    /// Additional task metadata.
83    #[serde(default, skip_serializing_if = "Metadata::is_empty")]
84    pub metadata: Metadata,
85    /// Task creation timestamp.
86    #[serde(default = "Utc::now")]
87    pub created_at: DateTime<Utc>,
88    /// Last update timestamp.
89    #[serde(default = "Utc::now")]
90    pub updated_at: DateTime<Utc>,
91}
92
93impl Task {
94    /// Create a pending task.
95    #[must_use]
96    pub fn new(
97        id: impl Into<String>,
98        subject: impl Into<String>,
99        description: impl Into<String>,
100    ) -> Self {
101        let now = Utc::now();
102        Self {
103            id: id.into(),
104            subject: subject.into(),
105            description: description.into(),
106            status: TaskStatus::Pending,
107            active_form: None,
108            owner: None,
109            blocked_by: Vec::new(),
110            blocks: Vec::new(),
111            metadata: Metadata::default(),
112            created_at: now,
113            updated_at: now,
114        }
115    }
116
117    /// Return whether task is blocked by any incomplete tasks.
118    #[must_use]
119    pub const fn is_blocked(&self) -> bool {
120        !self.blocked_by.is_empty()
121    }
122
123    /// Return status as a string.
124    #[must_use]
125    pub const fn status_str(&self) -> &str {
126        self.status.as_str()
127    }
128
129    /// Set status from a parsed status enum.
130    pub fn set_status(&mut self, status: TaskStatus) {
131        self.status = status;
132        self.updated_at = Utc::now();
133    }
134}
135
136/// Full task board snapshot transported through custom events.
137#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
138pub struct TaskSnapshot {
139    /// Full task list in display order.
140    pub tasks: Vec<Task>,
141}
142
143impl TaskSnapshot {
144    /// Return the snapshot as event payload.
145    #[must_use]
146    pub fn into_payload(self) -> Value {
147        serde_json::to_value(self).unwrap_or_else(|_| serde_json::json!({"tasks": []}))
148    }
149}
150
151/// Manager for task lifecycle and dependencies.
152#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
153pub struct TaskManager {
154    /// All tasks keyed by task ID.
155    #[serde(default)]
156    pub tasks: BTreeMap<String, Task>,
157}
158
159impl TaskManager {
160    /// Create an empty task manager.
161    #[must_use]
162    pub fn new() -> Self {
163        Self::default()
164    }
165
166    /// Create a new task.
167    pub fn create(
168        &mut self,
169        subject: impl Into<String>,
170        description: impl Into<String>,
171        active_form: Option<String>,
172        metadata: Metadata,
173    ) -> Task {
174        let id = self.next_id();
175        let mut task = Task::new(id.clone(), subject, description);
176        task.active_form = active_form;
177        task.metadata = metadata;
178        self.tasks.insert(id, task.clone());
179        task
180    }
181
182    /// Return a task by id.
183    #[must_use]
184    pub fn get(&self, task_id: &str) -> Option<&Task> {
185        self.tasks.get(task_id)
186    }
187
188    /// Return all tasks in stable numeric order.
189    #[must_use]
190    pub fn list_all(&self) -> Vec<Task> {
191        let mut tasks = self.tasks.values().cloned().collect::<Vec<_>>();
192        tasks.sort_by_key(|task| task_sort_key(&task.id));
193        tasks
194    }
195
196    /// Replace all tasks from display snapshot order.
197    pub fn replace_all(&mut self, tasks: Vec<Task>) {
198        self.tasks = tasks
199            .into_iter()
200            .map(|task| (normalize_task_id(&task.id), task))
201            .collect();
202    }
203
204    /// Update one task and resolve dependencies when completed.
205    #[allow(clippy::too_many_arguments)]
206    pub fn update(
207        &mut self,
208        task_id: &str,
209        status: Option<TaskStatus>,
210        subject: Option<String>,
211        description: Option<String>,
212        active_form: Option<Option<String>>,
213        owner: Option<Option<String>>,
214        add_blocks: Option<&[String]>,
215        add_blocked_by: Option<&[String]>,
216        metadata: Option<&Metadata>,
217    ) -> Option<Task> {
218        let task_id = normalize_task_id(task_id);
219        let was_completed = status.as_ref().is_some_and(TaskStatus::is_completed)
220            && self
221                .tasks
222                .get(&task_id)
223                .is_some_and(|task| !task.status.is_completed());
224        {
225            let task = self.tasks.get_mut(&task_id)?;
226            if let Some(status) = status {
227                task.status = status;
228            }
229            if let Some(subject) = subject.filter(|value| !value.trim().is_empty()) {
230                task.subject = subject;
231            }
232            if let Some(description) = description.filter(|value| !value.trim().is_empty()) {
233                task.description = description;
234            }
235            if let Some(active_form) = active_form {
236                task.active_form = active_form;
237            }
238            if let Some(owner) = owner {
239                task.owner = owner;
240            }
241            extend_unique(&mut task.blocks, add_blocks);
242            extend_unique(&mut task.blocked_by, add_blocked_by);
243            if let Some(metadata) = metadata {
244                for (key, value) in metadata {
245                    task.metadata.insert(key.clone(), value.clone());
246                }
247            }
248            task.updated_at = Utc::now();
249        }
250
251        for blocked_id in add_blocks.unwrap_or_default() {
252            self.add_blocking_relationship(&task_id, blocked_id);
253        }
254        for blocker_id in add_blocked_by.unwrap_or_default() {
255            self.add_blocked_by_relationship(&task_id, blocker_id);
256        }
257        if was_completed {
258            self.resolve_completion(&task_id);
259        }
260        self.tasks.get(&task_id).cloned()
261    }
262
263    /// Export task data keyed by task id.
264    #[must_use]
265    pub fn export_tasks(&self) -> BTreeMap<String, Value> {
266        self.tasks
267            .iter()
268            .map(|(id, task)| {
269                (
270                    id.clone(),
271                    serde_json::to_value(task).unwrap_or_else(|_| serde_json::json!({})),
272                )
273            })
274            .collect()
275    }
276
277    /// Restore from exported task data.
278    #[must_use]
279    pub fn from_exported(data: BTreeMap<String, Value>) -> Self {
280        let tasks = data
281            .into_iter()
282            .filter_map(|(id, value)| {
283                serde_json::from_value::<Task>(value)
284                    .ok()
285                    .map(|task| (id, task))
286            })
287            .collect();
288        Self { tasks }
289    }
290
291    /// Return whether the manager has no tasks.
292    #[must_use]
293    pub fn is_empty(&self) -> bool {
294        self.tasks.is_empty()
295    }
296
297    fn next_id(&self) -> String {
298        self.tasks
299            .keys()
300            .filter_map(|id| normalize_task_id(id).parse::<u64>().ok())
301            .max()
302            .unwrap_or(0)
303            .saturating_add(1)
304            .to_string()
305    }
306
307    fn add_blocking_relationship(&mut self, task_id: &str, blocked_id: &str) {
308        let blocked_id = normalize_task_id(blocked_id);
309        if let Some(task) = self.tasks.get_mut(task_id) {
310            extend_unique(&mut task.blocks, Some(std::slice::from_ref(&blocked_id)));
311        }
312        if let Some(blocked_task) = self.tasks.get_mut(&blocked_id) {
313            extend_unique(
314                &mut blocked_task.blocked_by,
315                Some(std::slice::from_ref(&task_id.to_string())),
316            );
317            blocked_task.updated_at = Utc::now();
318        }
319    }
320
321    fn add_blocked_by_relationship(&mut self, task_id: &str, blocker_id: &str) {
322        let blocker_id = normalize_task_id(blocker_id);
323        if let Some(task) = self.tasks.get_mut(task_id) {
324            extend_unique(
325                &mut task.blocked_by,
326                Some(std::slice::from_ref(&blocker_id)),
327            );
328        }
329        if let Some(blocker) = self.tasks.get_mut(&blocker_id) {
330            extend_unique(
331                &mut blocker.blocks,
332                Some(std::slice::from_ref(&task_id.to_string())),
333            );
334            blocker.updated_at = Utc::now();
335        }
336    }
337
338    fn resolve_completion(&mut self, task_id: &str) {
339        let Some(blocked_ids) = self.tasks.get(task_id).map(|task| task.blocks.clone()) else {
340            return;
341        };
342        for blocked_id in blocked_ids {
343            if let Some(blocked_task) = self.tasks.get_mut(&blocked_id) {
344                blocked_task.blocked_by.retain(|blocker| blocker != task_id);
345                blocked_task.updated_at = Utc::now();
346            }
347        }
348    }
349}
350
351fn normalize_task_id(id: &str) -> String {
352    id.trim().trim_start_matches('#').to_string()
353}
354
355fn task_sort_key(id: &str) -> (u8, u64, String) {
356    normalize_task_id(id).parse::<u64>().map_or_else(
357        |_| (1, 0, id.to_string()),
358        |value| (0, value, String::new()),
359    )
360}
361
362fn extend_unique(target: &mut Vec<String>, values: Option<&[String]>) {
363    let Some(values) = values else {
364        return;
365    };
366    let mut seen = target
367        .iter()
368        .cloned()
369        .collect::<std::collections::BTreeSet<_>>();
370    for value in values {
371        let normalized = normalize_task_id(value);
372        if !normalized.is_empty() && seen.insert(normalized.clone()) {
373            target.push(normalized);
374        }
375    }
376}