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