1use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum TaskStatus {
23 Pending,
24 InProgress,
25 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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70pub struct EvidenceEntry {
71 pub tool: String,
72 pub target: String,
73 pub status: String,
74}
75
76pub const EVIDENCE_CAP: usize = 20;
78
79#[derive(Debug, Clone, Copy, Default)]
82pub struct Stamp {
83 pub now_epoch: u64,
85 pub run_tokens: u64,
87}
88
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90pub struct TaskItem {
91 pub id: u32,
93 pub subject: String,
95 pub active_form: String,
97 #[serde(default)]
98 pub description: Option<String>,
99 pub status: TaskStatus,
100 #[serde(default)]
101 pub origin: TaskOrigin,
102 #[serde(default)]
104 pub started_at: Option<u64>,
105 #[serde(default)]
107 pub completed_at: Option<u64>,
108 #[serde(default)]
111 pub tokens_at_start: Option<u64>,
112 #[serde(default)]
114 pub tokens_spent: Option<u64>,
115 #[serde(default)]
117 pub evidence: Vec<EvidenceEntry>,
118}
119
120impl TaskItem {
121 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#[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#[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#[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#[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 #[serde(default)]
179 pub next_id: u32,
180}
181
182impl TaskStore {
183 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 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 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 pub fn visible(&self) -> impl Iterator<Item = &TaskItem> {
266 self.tasks
267 .iter()
268 .filter(|t| t.status != TaskStatus::Deleted)
269 }
270
271 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 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 pub fn active(&self) -> Option<&TaskItem> {
301 self.tasks
302 .iter()
303 .find(|t| t.status == TaskStatus::InProgress)
304 }
305
306 pub fn next_pending(&self) -> Option<&TaskItem> {
308 self.tasks.iter().find(|t| t.status == TaskStatus::Pending)
309 }
310
311 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
327fn 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 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 TaskStatus::Pending | TaskStatus::Blocked | TaskStatus::Deleted => {},
354 }
355 task.status = status;
356}
357
358pub 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 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 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 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}