Skip to main content

mermaid_cli/domain/
tasks.rs

1//! The task checklist: differential, id-addressed todo tracking.
2//!
3//! Pure data + pure operations. The `TaskBroker` (`crate::providers::tasks`)
4//! owns the authoritative store and is the only writer; every mutation
5//! publishes a full snapshot via `Msg::TasksUpdated`, and the reducer's copy
6//! on `Session.tasks` is render/persist truth. Timestamps and token readings
7//! arrive here as plain arguments ([`Stamp`]) — nothing in this module reads
8//! a clock, so it stays inside the domain-purity fence and `--replay` stays
9//! deterministic.
10//!
11//! Design synthesizes Claude Code's granular Task tools (stable ids,
12//! differential updates) with codex's `update_plan` (batch calls, per-update
13//! `explanation`), plus mermaid-only extensions: per-task cost stamps, a
14//! bounded evidence ring, and user-originated tasks (`/tasks add`).
15
16use serde::{Deserialize, Serialize};
17
18/// Lifecycle of one checklist item. `Deleted` items stay in the store as
19/// tombstones so ids are never reused or re-resolved.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum TaskStatus {
23    Pending,
24    InProgress,
25    /// Stalled on something outside the task itself (a failing dependency, a
26    /// missing decision). Stays visibly blocked instead of masquerading as
27    /// pending; the blocker gets its own task, which becomes the in_progress
28    /// one.
29    Blocked,
30    Completed,
31    Deleted,
32}
33
34impl TaskStatus {
35    pub fn as_str(self) -> &'static str {
36        match self {
37            Self::Pending => "pending",
38            Self::InProgress => "in_progress",
39            Self::Blocked => "blocked",
40            Self::Completed => "completed",
41            Self::Deleted => "deleted",
42        }
43    }
44
45    pub fn parse(s: &str) -> Option<Self> {
46        match s {
47            "pending" => Some(Self::Pending),
48            "in_progress" => Some(Self::InProgress),
49            "blocked" => Some(Self::Blocked),
50            "completed" => Some(Self::Completed),
51            "deleted" => Some(Self::Deleted),
52            _ => None,
53        }
54    }
55}
56
57/// Who created the task. User-originated tasks (`/tasks add`) render with a
58/// marker and are called out to the model in a turn-start notice.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum TaskOrigin {
62    #[default]
63    Model,
64    User,
65}
66
67/// One recorded piece of work attributed to a task while it was in progress:
68/// which tool ran, against what, and how it ended.
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70pub struct EvidenceEntry {
71    pub tool: String,
72    pub target: String,
73    pub status: String,
74}
75
76/// Cap on the per-task evidence ring; oldest entries are dropped first.
77pub const EVIDENCE_CAP: usize = 20;
78
79/// Wall-clock + run-token reading supplied by the impure side at mutation
80/// time. Optional everywhere so pure tests and replay never fabricate one.
81#[derive(Debug, Clone, Copy, Default)]
82pub struct Stamp {
83    /// Seconds since the Unix epoch.
84    pub now_epoch: u64,
85    /// The run's committed-token counter at this moment.
86    pub run_tokens: u64,
87}
88
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90pub struct TaskItem {
91    /// Stable, monotonic, never reused.
92    pub id: u32,
93    /// Short imperative description ("Wire the broker into ExecContext").
94    pub subject: String,
95    /// Present-tense form shown on the spinner line while in progress.
96    pub active_form: String,
97    #[serde(default)]
98    pub description: Option<String>,
99    pub status: TaskStatus,
100    #[serde(default)]
101    pub origin: TaskOrigin,
102    /// Epoch seconds when the task first entered `InProgress`.
103    #[serde(default)]
104    pub started_at: Option<u64>,
105    /// Epoch seconds when the task entered `Completed`.
106    #[serde(default)]
107    pub completed_at: Option<u64>,
108    /// Run-token counter reading when the task entered `InProgress`;
109    /// bookkeeping for `tokens_spent`, not user-facing.
110    #[serde(default)]
111    pub tokens_at_start: Option<u64>,
112    /// Run-token delta between entering `InProgress` and `Completed`.
113    #[serde(default)]
114    pub tokens_spent: Option<u64>,
115    /// Bounded ring of work performed while this task was in progress.
116    #[serde(default)]
117    pub evidence: Vec<EvidenceEntry>,
118}
119
120impl TaskItem {
121    /// Elapsed seconds from start to completion, when both stamps exist.
122    pub fn elapsed_secs(&self) -> Option<u64> {
123        match (self.started_at, self.completed_at) {
124            (Some(s), Some(c)) => Some(c.saturating_sub(s)),
125            (Some(_), None) | (None, Some(_)) | (None, None) => None,
126        }
127    }
128}
129
130/// A new task requested via `task_create` (or `/tasks add`), before an id is
131/// assigned.
132#[derive(Debug, Clone)]
133pub struct TaskSpec {
134    pub subject: String,
135    pub active_form: String,
136    pub description: Option<String>,
137    pub in_progress: bool,
138}
139
140/// One differential edit requested via `task_update` (or `/tasks rm|done`).
141/// Only `id` is required; absent fields are left untouched.
142#[derive(Debug, Clone, Default)]
143pub struct TaskEdit {
144    pub id: u32,
145    pub status: Option<TaskStatus>,
146    pub subject: Option<String>,
147    pub active_form: Option<String>,
148    pub description: Option<String>,
149}
150
151/// A checklist edit made by the user via `/tasks` (not the model). Carried
152/// on `Cmd::UserTaskEdit` to the effect runner, which applies it through the
153/// `TaskBroker` — the single writer — so user edits and concurrent tool calls
154/// serialize instead of racing.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum UserTaskEdit {
157    Add { subject: String },
158    Remove { id: u32 },
159    Done { id: u32 },
160    Clear,
161}
162
163/// Result of applying a batch of [`TaskEdit`]s: which ids were applied,
164/// per-item errors (unknown/deleted ids), and soft-validation notes for the
165/// model. Errors never abort the rest of the batch.
166#[derive(Debug, Clone, Default, PartialEq)]
167pub struct ApplyReport {
168    pub applied: Vec<u32>,
169    pub errors: Vec<String>,
170    pub notes: Vec<String>,
171}
172
173#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
174pub struct TaskStore {
175    #[serde(default)]
176    pub tasks: Vec<TaskItem>,
177    /// Last id handed out; 0 means the first task gets id 1.
178    #[serde(default)]
179    pub next_id: u32,
180}
181
182impl TaskStore {
183    /// Append new tasks in order, assigning ids. Returns the assigned ids.
184    /// A spec flagged `in_progress` is stamped as started.
185    pub fn create(&mut self, specs: Vec<TaskSpec>, origin: TaskOrigin, stamp: Stamp) -> Vec<u32> {
186        let mut ids = Vec::with_capacity(specs.len());
187        for spec in specs {
188            self.next_id += 1;
189            let in_progress = spec.in_progress;
190            self.tasks.push(TaskItem {
191                id: self.next_id,
192                subject: spec.subject,
193                active_form: spec.active_form,
194                description: spec.description,
195                status: if in_progress {
196                    TaskStatus::InProgress
197                } else {
198                    TaskStatus::Pending
199                },
200                origin,
201                started_at: in_progress.then_some(stamp.now_epoch),
202                completed_at: None,
203                tokens_at_start: in_progress.then_some(stamp.run_tokens),
204                tokens_spent: None,
205                evidence: Vec::new(),
206            });
207            ids.push(self.next_id);
208        }
209        ids
210    }
211
212    /// Apply each edit independently; unknown or deleted ids become per-item
213    /// errors, valid edits still land. Status transitions pick up cost stamps.
214    /// Soft-validation notes compare the store before and after the batch.
215    pub fn apply(&mut self, edits: &[TaskEdit], stamp: Stamp) -> ApplyReport {
216        let before = self.clone();
217        let mut report = ApplyReport::default();
218        for edit in edits {
219            let Some(task) = self.tasks.iter_mut().find(|t| t.id == edit.id) else {
220                report.errors.push(format!("#{}: no such task", edit.id));
221                continue;
222            };
223            if task.status == TaskStatus::Deleted && edit.status != Some(TaskStatus::Deleted) {
224                report
225                    .errors
226                    .push(format!("#{}: task was deleted; create a new one", edit.id));
227                continue;
228            }
229            if let Some(subject) = &edit.subject {
230                task.subject = subject.clone();
231            }
232            if let Some(active_form) = &edit.active_form {
233                task.active_form = active_form.clone();
234            }
235            if let Some(description) = &edit.description {
236                task.description = Some(description.clone());
237            }
238            if let Some(status) = edit.status {
239                transition(task, status, stamp);
240            }
241            report.applied.push(edit.id);
242        }
243        report.notes = advisory_notes(&before, edits, self);
244        report
245    }
246
247    /// Record evidence against the current in-progress task, if any.
248    /// Returns whether an entry was recorded.
249    pub fn record_evidence(&mut self, entry: EvidenceEntry) -> bool {
250        let Some(task) = self
251            .tasks
252            .iter_mut()
253            .find(|t| t.status == TaskStatus::InProgress)
254        else {
255            return false;
256        };
257        if task.evidence.len() >= EVIDENCE_CAP {
258            task.evidence.remove(0);
259        }
260        task.evidence.push(entry);
261        true
262    }
263
264    /// All non-deleted tasks, in creation order.
265    pub fn visible(&self) -> impl Iterator<Item = &TaskItem> {
266        self.tasks
267            .iter()
268            .filter(|t| t.status != TaskStatus::Deleted)
269    }
270
271    /// `(completed, total)` over visible tasks.
272    pub fn counts(&self) -> (usize, usize) {
273        let mut completed = 0;
274        let mut total = 0;
275        for task in self.visible() {
276            total += 1;
277            if task.status == TaskStatus::Completed {
278                completed += 1;
279            }
280        }
281        (completed, total)
282    }
283
284    /// Compact progress label, e.g. `Tasks 2/5`.
285    pub fn progress_string(&self) -> String {
286        let (completed, total) = self.counts();
287        format!("Tasks {completed}/{total}")
288    }
289
290    pub fn is_empty(&self) -> bool {
291        self.visible().next().is_none()
292    }
293
294    pub fn all_done(&self) -> bool {
295        let (completed, total) = self.counts();
296        total > 0 && completed == total
297    }
298
299    /// The current in-progress task (first, if the model broke discipline).
300    pub fn active(&self) -> Option<&TaskItem> {
301        self.tasks
302            .iter()
303            .find(|t| t.status == TaskStatus::InProgress)
304    }
305
306    /// The next pending task, in creation order.
307    pub fn next_pending(&self) -> Option<&TaskItem> {
308        self.tasks.iter().find(|t| t.status == TaskStatus::Pending)
309    }
310
311    /// Tasks that flipped to `Completed` relative to `before` — drives the
312    /// `task_completed` hook.
313    pub fn newly_completed<'a>(&'a self, before: &TaskStore) -> Vec<&'a TaskItem> {
314        self.visible()
315            .filter(|t| {
316                t.status == TaskStatus::Completed
317                    && before
318                        .tasks
319                        .iter()
320                        .find(|b| b.id == t.id)
321                        .is_none_or(|b| b.status != TaskStatus::Completed)
322            })
323            .collect()
324    }
325}
326
327/// Apply a status change, maintaining cost stamps on the edges:
328/// entering `InProgress` stamps start time/tokens (first entry only —
329/// a veto re-entry keeps the original start), entering `Completed`
330/// stamps completion and the token delta.
331fn transition(task: &mut TaskItem, status: TaskStatus, stamp: Stamp) {
332    if task.status == status {
333        return;
334    }
335    match status {
336        TaskStatus::InProgress => {
337            if task.started_at.is_none() {
338                task.started_at = Some(stamp.now_epoch);
339                task.tokens_at_start = Some(stamp.run_tokens);
340            }
341            // Re-opened after completion or a vetoed completion: clear the end stamps.
342            task.completed_at = None;
343            task.tokens_spent = None;
344        },
345        TaskStatus::Completed => {
346            task.completed_at = Some(stamp.now_epoch);
347            task.tokens_spent = task
348                .tokens_at_start
349                .map(|start| stamp.run_tokens.saturating_sub(start));
350        },
351        // Blocked keeps `started_at`, so a later return to in_progress
352        // preserves the original start stamp (see the InProgress arm).
353        TaskStatus::Pending | TaskStatus::Blocked | TaskStatus::Deleted => {},
354    }
355    task.status = status;
356}
357
358/// Soft validation: advisory notes appended to the tool result, never a
359/// rejection (a hard reject risks retry loops; codex's enforce-nothing
360/// approach lets malformed checklists render silently). Strictness changes
361/// edit this list of checks only.
362pub fn advisory_notes(before: &TaskStore, edits: &[TaskEdit], after: &TaskStore) -> Vec<String> {
363    let mut notes = Vec::new();
364    notes.extend(check_single_in_progress(after));
365    notes.extend(check_no_status_jump(before, edits));
366    notes
367}
368
369fn check_single_in_progress(after: &TaskStore) -> Option<String> {
370    let in_progress: Vec<u32> = after
371        .visible()
372        .filter(|t| t.status == TaskStatus::InProgress)
373        .map(|t| t.id)
374        .collect();
375    (in_progress.len() > 1).then(|| {
376        let ids = in_progress
377            .iter()
378            .map(|id| format!("#{id}"))
379            .collect::<Vec<_>>()
380            .join(", ");
381        format!("Note: {ids} are all in_progress; keep at most one task in_progress at a time.")
382    })
383}
384
385fn check_no_status_jump(before: &TaskStore, edits: &[TaskEdit]) -> Option<String> {
386    let jumped: Vec<u32> = edits
387        .iter()
388        .filter(|e| e.status == Some(TaskStatus::Completed))
389        .filter(|e| {
390            before
391                .tasks
392                .iter()
393                .any(|t| t.id == e.id && t.status == TaskStatus::Pending)
394        })
395        .map(|e| e.id)
396        .collect();
397    (!jumped.is_empty()).then(|| {
398        let ids = jumped
399            .iter()
400            .map(|id| format!("#{id}"))
401            .collect::<Vec<_>>()
402            .join(", ");
403        format!(
404            "Note: {ids} jumped from pending straight to completed; mark a task in_progress while working on it."
405        )
406    })
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    fn spec(subject: &str) -> TaskSpec {
414        TaskSpec {
415            subject: subject.into(),
416            active_form: format!("{subject}ing"),
417            description: None,
418            in_progress: false,
419        }
420    }
421
422    fn store_with(n: u32) -> TaskStore {
423        let mut store = TaskStore::default();
424        store.create(
425            (0..n).map(|i| spec(&format!("task {i}"))).collect(),
426            TaskOrigin::Model,
427            Stamp::default(),
428        );
429        store
430    }
431
432    fn edit(id: u32, status: TaskStatus) -> TaskEdit {
433        TaskEdit {
434            id,
435            status: Some(status),
436            ..TaskEdit::default()
437        }
438    }
439
440    #[test]
441    fn ids_are_monotonic_across_deletes() {
442        let mut store = store_with(2);
443        store.apply(&[edit(2, TaskStatus::Deleted)], Stamp::default());
444        let ids = store.create(vec![spec("later")], TaskOrigin::Model, Stamp::default());
445        assert_eq!(ids, vec![3]);
446        assert_eq!(store.visible().count(), 2);
447    }
448
449    #[test]
450    fn apply_reports_partial_failure() {
451        let mut store = store_with(1);
452        let report = store.apply(
453            &[
454                edit(1, TaskStatus::InProgress),
455                edit(9, TaskStatus::Completed),
456            ],
457            Stamp::default(),
458        );
459        assert_eq!(report.applied, vec![1]);
460        assert_eq!(report.errors, vec!["#9: no such task"]);
461    }
462
463    #[test]
464    fn deleted_tasks_reject_edits_and_hide() {
465        let mut store = store_with(1);
466        store.apply(&[edit(1, TaskStatus::Deleted)], Stamp::default());
467        let report = store.apply(&[edit(1, TaskStatus::InProgress)], Stamp::default());
468        assert!(report.applied.is_empty());
469        assert_eq!(report.errors.len(), 1);
470        assert!(store.is_empty());
471    }
472
473    #[test]
474    fn blocked_round_trips_and_stays_out_of_the_flow() {
475        assert_eq!(TaskStatus::parse("blocked"), Some(TaskStatus::Blocked));
476        assert_eq!(TaskStatus::Blocked.as_str(), "blocked");
477
478        let mut store = store_with(2);
479        store.apply(&[edit(1, TaskStatus::Blocked)], Stamp::default());
480        // Blocked is neither the active task nor the next pending one, and it
481        // keeps the list from reading all-done.
482        assert!(store.active().is_none());
483        assert_eq!(store.next_pending().map(|t| t.id), Some(2));
484        store.apply(&[edit(2, TaskStatus::Completed)], Stamp::default());
485        assert!(!store.all_done());
486        assert_eq!(store.counts(), (1, 2));
487    }
488
489    #[test]
490    fn blocked_preserves_the_original_start_stamp() {
491        let mut store = store_with(1);
492        store.apply(
493            &[edit(1, TaskStatus::InProgress)],
494            Stamp {
495                now_epoch: 100,
496                run_tokens: 1_000,
497            },
498        );
499        store.apply(
500            &[edit(1, TaskStatus::Blocked)],
501            Stamp {
502                now_epoch: 200,
503                run_tokens: 2_000,
504            },
505        );
506        store.apply(
507            &[edit(1, TaskStatus::InProgress)],
508            Stamp {
509                now_epoch: 300,
510                run_tokens: 3_000,
511            },
512        );
513        let task = store.tasks.iter().find(|t| t.id == 1).unwrap();
514        assert_eq!(task.status, TaskStatus::InProgress);
515        assert_eq!(
516            task.started_at,
517            Some(100),
518            "unblocking must keep the original start stamp"
519        );
520        assert_eq!(task.tokens_at_start, Some(1_000));
521    }
522
523    #[test]
524    fn dual_in_progress_yields_note() {
525        let mut store = store_with(2);
526        let report = store.apply(
527            &[
528                edit(1, TaskStatus::InProgress),
529                edit(2, TaskStatus::InProgress),
530            ],
531            Stamp::default(),
532        );
533        assert_eq!(report.notes.len(), 1);
534        assert!(report.notes[0].contains("#1, #2"));
535    }
536
537    #[test]
538    fn pending_to_completed_jump_yields_note() {
539        let mut store = store_with(1);
540        let report = store.apply(&[edit(1, TaskStatus::Completed)], Stamp::default());
541        assert_eq!(report.notes.len(), 1);
542        assert!(report.notes[0].contains("pending straight to completed"));
543    }
544
545    #[test]
546    fn clean_update_yields_no_notes() {
547        let mut store = store_with(2);
548        store.apply(&[edit(1, TaskStatus::InProgress)], Stamp::default());
549        let report = store.apply(
550            &[
551                edit(1, TaskStatus::Completed),
552                edit(2, TaskStatus::InProgress),
553            ],
554            Stamp::default(),
555        );
556        assert!(report.notes.is_empty(), "{:?}", report.notes);
557        assert!(report.errors.is_empty());
558    }
559
560    #[test]
561    fn cost_stamps_ride_the_transitions() {
562        let mut store = store_with(1);
563        store.apply(
564            &[edit(1, TaskStatus::InProgress)],
565            Stamp {
566                now_epoch: 100,
567                run_tokens: 1_000,
568            },
569        );
570        store.apply(
571            &[edit(1, TaskStatus::Completed)],
572            Stamp {
573                now_epoch: 230,
574                run_tokens: 9_400,
575            },
576        );
577        let task = &store.tasks[0];
578        assert_eq!(task.elapsed_secs(), Some(130));
579        assert_eq!(task.tokens_spent, Some(8_400));
580    }
581
582    #[test]
583    fn veto_reopen_keeps_original_start() {
584        let mut store = store_with(1);
585        store.apply(
586            &[edit(1, TaskStatus::InProgress)],
587            Stamp {
588                now_epoch: 100,
589                run_tokens: 10,
590            },
591        );
592        store.apply(
593            &[edit(1, TaskStatus::Completed)],
594            Stamp {
595                now_epoch: 200,
596                run_tokens: 20,
597            },
598        );
599        store.apply(
600            &[edit(1, TaskStatus::InProgress)],
601            Stamp {
602                now_epoch: 300,
603                run_tokens: 30,
604            },
605        );
606        let task = &store.tasks[0];
607        assert_eq!(task.started_at, Some(100));
608        assert_eq!(task.completed_at, None);
609        assert_eq!(task.tokens_spent, None);
610    }
611
612    #[test]
613    fn evidence_ring_is_bounded_and_targets_active() {
614        let mut store = store_with(2);
615        assert!(!store.record_evidence(EvidenceEntry {
616            tool: "edit_file".into(),
617            target: "a.rs".into(),
618            status: "ok".into(),
619        }));
620        store.apply(&[edit(2, TaskStatus::InProgress)], Stamp::default());
621        for i in 0..(EVIDENCE_CAP + 5) {
622            assert!(store.record_evidence(EvidenceEntry {
623                tool: "execute_command".into(),
624                target: format!("cmd {i}"),
625                status: "ok".into(),
626            }));
627        }
628        let task = store.tasks.iter().find(|t| t.id == 2).unwrap();
629        assert_eq!(task.evidence.len(), EVIDENCE_CAP);
630        assert_eq!(task.evidence[0].target, "cmd 5");
631    }
632
633    #[test]
634    fn progress_and_active_and_next() {
635        let mut store = store_with(3);
636        store.apply(&[edit(1, TaskStatus::InProgress)], Stamp::default());
637        store.apply(
638            &[
639                edit(1, TaskStatus::Completed),
640                edit(2, TaskStatus::InProgress),
641            ],
642            Stamp::default(),
643        );
644        assert_eq!(store.progress_string(), "Tasks 1/3");
645        assert_eq!(store.active().unwrap().id, 2);
646        assert_eq!(store.next_pending().unwrap().id, 3);
647        assert!(!store.all_done());
648    }
649
650    #[test]
651    fn newly_completed_diff() {
652        let mut store = store_with(2);
653        store.apply(&[edit(1, TaskStatus::InProgress)], Stamp::default());
654        let before = store.clone();
655        store.apply(&[edit(1, TaskStatus::Completed)], Stamp::default());
656        let fresh = store.newly_completed(&before);
657        assert_eq!(fresh.len(), 1);
658        assert_eq!(fresh[0].id, 1);
659        // Re-diff against the new state: nothing fresh.
660        assert!(store.newly_completed(&store.clone()).is_empty());
661    }
662
663    #[test]
664    fn serde_roundtrip_and_legacy_defaults() {
665        let mut store = store_with(1);
666        store.apply(&[edit(1, TaskStatus::InProgress)], Stamp::default());
667        let json = serde_json::to_string(&store).unwrap();
668        let back: TaskStore = serde_json::from_str(&json).unwrap();
669        assert_eq!(store, back);
670        // A minimal item (as an older snapshot would carry) still loads.
671        let legacy: TaskItem =
672            serde_json::from_str(r#"{"id":1,"subject":"s","active_form":"a","status":"pending"}"#)
673                .unwrap();
674        assert_eq!(legacy.origin, TaskOrigin::Model);
675        assert!(legacy.evidence.is_empty());
676    }
677}