use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskStatus {
Pending,
InProgress,
Blocked,
Completed,
Deleted,
}
impl TaskStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::InProgress => "in_progress",
Self::Blocked => "blocked",
Self::Completed => "completed",
Self::Deleted => "deleted",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"pending" => Some(Self::Pending),
"in_progress" => Some(Self::InProgress),
"blocked" => Some(Self::Blocked),
"completed" => Some(Self::Completed),
"deleted" => Some(Self::Deleted),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskOrigin {
#[default]
Model,
User,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EvidenceEntry {
pub tool: String,
pub target: String,
pub status: String,
}
pub const EVIDENCE_CAP: usize = 20;
#[derive(Debug, Clone, Copy, Default)]
pub struct Stamp {
pub now_epoch: u64,
pub run_tokens: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TaskItem {
pub id: u32,
pub subject: String,
pub active_form: String,
#[serde(default)]
pub description: Option<String>,
pub status: TaskStatus,
#[serde(default)]
pub origin: TaskOrigin,
#[serde(default)]
pub started_at: Option<u64>,
#[serde(default)]
pub completed_at: Option<u64>,
#[serde(default)]
pub tokens_at_start: Option<u64>,
#[serde(default)]
pub tokens_spent: Option<u64>,
#[serde(default)]
pub evidence: Vec<EvidenceEntry>,
}
impl TaskItem {
pub fn elapsed_secs(&self) -> Option<u64> {
match (self.started_at, self.completed_at) {
(Some(s), Some(c)) => Some(c.saturating_sub(s)),
(Some(_), None) | (None, Some(_)) | (None, None) => None,
}
}
}
#[derive(Debug, Clone)]
pub struct TaskSpec {
pub subject: String,
pub active_form: String,
pub description: Option<String>,
pub in_progress: bool,
}
#[derive(Debug, Clone, Default)]
pub struct TaskEdit {
pub id: u32,
pub status: Option<TaskStatus>,
pub subject: Option<String>,
pub active_form: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UserTaskEdit {
Add { subject: String },
Remove { id: u32 },
Done { id: u32 },
Clear,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ApplyReport {
pub applied: Vec<u32>,
pub errors: Vec<String>,
pub notes: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct TaskStore {
#[serde(default)]
pub tasks: Vec<TaskItem>,
#[serde(default)]
pub next_id: u32,
}
impl TaskStore {
pub fn create(&mut self, specs: Vec<TaskSpec>, origin: TaskOrigin, stamp: Stamp) -> Vec<u32> {
let mut ids = Vec::with_capacity(specs.len());
for spec in specs {
self.next_id += 1;
let in_progress = spec.in_progress;
self.tasks.push(TaskItem {
id: self.next_id,
subject: spec.subject,
active_form: spec.active_form,
description: spec.description,
status: if in_progress {
TaskStatus::InProgress
} else {
TaskStatus::Pending
},
origin,
started_at: in_progress.then_some(stamp.now_epoch),
completed_at: None,
tokens_at_start: in_progress.then_some(stamp.run_tokens),
tokens_spent: None,
evidence: Vec::new(),
});
ids.push(self.next_id);
}
ids
}
pub fn apply(&mut self, edits: &[TaskEdit], stamp: Stamp) -> ApplyReport {
let before = self.clone();
let mut report = ApplyReport::default();
for edit in edits {
let Some(task) = self.tasks.iter_mut().find(|t| t.id == edit.id) else {
report.errors.push(format!("#{}: no such task", edit.id));
continue;
};
if task.status == TaskStatus::Deleted && edit.status != Some(TaskStatus::Deleted) {
report
.errors
.push(format!("#{}: task was deleted; create a new one", edit.id));
continue;
}
if let Some(subject) = &edit.subject {
task.subject = subject.clone();
}
if let Some(active_form) = &edit.active_form {
task.active_form = active_form.clone();
}
if let Some(description) = &edit.description {
task.description = Some(description.clone());
}
if let Some(status) = edit.status {
transition(task, status, stamp);
}
report.applied.push(edit.id);
}
report.notes = advisory_notes(&before, edits, self);
report
}
pub fn record_evidence(&mut self, entry: EvidenceEntry) -> bool {
let Some(task) = self
.tasks
.iter_mut()
.find(|t| t.status == TaskStatus::InProgress)
else {
return false;
};
if task.evidence.len() >= EVIDENCE_CAP {
task.evidence.remove(0);
}
task.evidence.push(entry);
true
}
pub fn visible(&self) -> impl Iterator<Item = &TaskItem> {
self.tasks
.iter()
.filter(|t| t.status != TaskStatus::Deleted)
}
pub fn counts(&self) -> (usize, usize) {
let mut completed = 0;
let mut total = 0;
for task in self.visible() {
total += 1;
if task.status == TaskStatus::Completed {
completed += 1;
}
}
(completed, total)
}
pub fn progress_string(&self) -> String {
let (completed, total) = self.counts();
format!("Tasks {completed}/{total}")
}
pub fn is_empty(&self) -> bool {
self.visible().next().is_none()
}
pub fn all_done(&self) -> bool {
let (completed, total) = self.counts();
total > 0 && completed == total
}
pub fn active(&self) -> Option<&TaskItem> {
self.tasks
.iter()
.find(|t| t.status == TaskStatus::InProgress)
}
pub fn next_pending(&self) -> Option<&TaskItem> {
self.tasks.iter().find(|t| t.status == TaskStatus::Pending)
}
pub fn newly_completed<'a>(&'a self, before: &TaskStore) -> Vec<&'a TaskItem> {
self.visible()
.filter(|t| {
t.status == TaskStatus::Completed
&& before
.tasks
.iter()
.find(|b| b.id == t.id)
.is_none_or(|b| b.status != TaskStatus::Completed)
})
.collect()
}
}
fn transition(task: &mut TaskItem, status: TaskStatus, stamp: Stamp) {
if task.status == status {
return;
}
match status {
TaskStatus::InProgress => {
if task.started_at.is_none() {
task.started_at = Some(stamp.now_epoch);
task.tokens_at_start = Some(stamp.run_tokens);
}
task.completed_at = None;
task.tokens_spent = None;
},
TaskStatus::Completed => {
task.completed_at = Some(stamp.now_epoch);
task.tokens_spent = task
.tokens_at_start
.map(|start| stamp.run_tokens.saturating_sub(start));
},
TaskStatus::Pending | TaskStatus::Blocked | TaskStatus::Deleted => {},
}
task.status = status;
}
pub fn advisory_notes(before: &TaskStore, edits: &[TaskEdit], after: &TaskStore) -> Vec<String> {
let mut notes = Vec::new();
notes.extend(check_single_in_progress(after));
notes.extend(check_no_status_jump(before, edits));
notes
}
fn check_single_in_progress(after: &TaskStore) -> Option<String> {
let in_progress: Vec<u32> = after
.visible()
.filter(|t| t.status == TaskStatus::InProgress)
.map(|t| t.id)
.collect();
(in_progress.len() > 1).then(|| {
let ids = in_progress
.iter()
.map(|id| format!("#{id}"))
.collect::<Vec<_>>()
.join(", ");
format!("Note: {ids} are all in_progress; keep at most one task in_progress at a time.")
})
}
fn check_no_status_jump(before: &TaskStore, edits: &[TaskEdit]) -> Option<String> {
let jumped: Vec<u32> = edits
.iter()
.filter(|e| e.status == Some(TaskStatus::Completed))
.filter(|e| {
before
.tasks
.iter()
.any(|t| t.id == e.id && t.status == TaskStatus::Pending)
})
.map(|e| e.id)
.collect();
(!jumped.is_empty()).then(|| {
let ids = jumped
.iter()
.map(|id| format!("#{id}"))
.collect::<Vec<_>>()
.join(", ");
format!(
"Note: {ids} jumped from pending straight to completed; mark a task in_progress while working on it."
)
})
}
#[cfg(test)]
mod tests {
use super::*;
fn spec(subject: &str) -> TaskSpec {
TaskSpec {
subject: subject.into(),
active_form: format!("{subject}ing"),
description: None,
in_progress: false,
}
}
fn store_with(n: u32) -> TaskStore {
let mut store = TaskStore::default();
store.create(
(0..n).map(|i| spec(&format!("task {i}"))).collect(),
TaskOrigin::Model,
Stamp::default(),
);
store
}
fn edit(id: u32, status: TaskStatus) -> TaskEdit {
TaskEdit {
id,
status: Some(status),
..TaskEdit::default()
}
}
#[test]
fn ids_are_monotonic_across_deletes() {
let mut store = store_with(2);
store.apply(&[edit(2, TaskStatus::Deleted)], Stamp::default());
let ids = store.create(vec![spec("later")], TaskOrigin::Model, Stamp::default());
assert_eq!(ids, vec![3]);
assert_eq!(store.visible().count(), 2);
}
#[test]
fn apply_reports_partial_failure() {
let mut store = store_with(1);
let report = store.apply(
&[
edit(1, TaskStatus::InProgress),
edit(9, TaskStatus::Completed),
],
Stamp::default(),
);
assert_eq!(report.applied, vec![1]);
assert_eq!(report.errors, vec!["#9: no such task"]);
}
#[test]
fn deleted_tasks_reject_edits_and_hide() {
let mut store = store_with(1);
store.apply(&[edit(1, TaskStatus::Deleted)], Stamp::default());
let report = store.apply(&[edit(1, TaskStatus::InProgress)], Stamp::default());
assert!(report.applied.is_empty());
assert_eq!(report.errors.len(), 1);
assert!(store.is_empty());
}
#[test]
fn blocked_round_trips_and_stays_out_of_the_flow() {
assert_eq!(TaskStatus::parse("blocked"), Some(TaskStatus::Blocked));
assert_eq!(TaskStatus::Blocked.as_str(), "blocked");
let mut store = store_with(2);
store.apply(&[edit(1, TaskStatus::Blocked)], Stamp::default());
assert!(store.active().is_none());
assert_eq!(store.next_pending().map(|t| t.id), Some(2));
store.apply(&[edit(2, TaskStatus::Completed)], Stamp::default());
assert!(!store.all_done());
assert_eq!(store.counts(), (1, 2));
}
#[test]
fn blocked_preserves_the_original_start_stamp() {
let mut store = store_with(1);
store.apply(
&[edit(1, TaskStatus::InProgress)],
Stamp {
now_epoch: 100,
run_tokens: 1_000,
},
);
store.apply(
&[edit(1, TaskStatus::Blocked)],
Stamp {
now_epoch: 200,
run_tokens: 2_000,
},
);
store.apply(
&[edit(1, TaskStatus::InProgress)],
Stamp {
now_epoch: 300,
run_tokens: 3_000,
},
);
let task = store.tasks.iter().find(|t| t.id == 1).unwrap();
assert_eq!(task.status, TaskStatus::InProgress);
assert_eq!(
task.started_at,
Some(100),
"unblocking must keep the original start stamp"
);
assert_eq!(task.tokens_at_start, Some(1_000));
}
#[test]
fn dual_in_progress_yields_note() {
let mut store = store_with(2);
let report = store.apply(
&[
edit(1, TaskStatus::InProgress),
edit(2, TaskStatus::InProgress),
],
Stamp::default(),
);
assert_eq!(report.notes.len(), 1);
assert!(report.notes[0].contains("#1, #2"));
}
#[test]
fn pending_to_completed_jump_yields_note() {
let mut store = store_with(1);
let report = store.apply(&[edit(1, TaskStatus::Completed)], Stamp::default());
assert_eq!(report.notes.len(), 1);
assert!(report.notes[0].contains("pending straight to completed"));
}
#[test]
fn clean_update_yields_no_notes() {
let mut store = store_with(2);
store.apply(&[edit(1, TaskStatus::InProgress)], Stamp::default());
let report = store.apply(
&[
edit(1, TaskStatus::Completed),
edit(2, TaskStatus::InProgress),
],
Stamp::default(),
);
assert!(report.notes.is_empty(), "{:?}", report.notes);
assert!(report.errors.is_empty());
}
#[test]
fn cost_stamps_ride_the_transitions() {
let mut store = store_with(1);
store.apply(
&[edit(1, TaskStatus::InProgress)],
Stamp {
now_epoch: 100,
run_tokens: 1_000,
},
);
store.apply(
&[edit(1, TaskStatus::Completed)],
Stamp {
now_epoch: 230,
run_tokens: 9_400,
},
);
let task = &store.tasks[0];
assert_eq!(task.elapsed_secs(), Some(130));
assert_eq!(task.tokens_spent, Some(8_400));
}
#[test]
fn veto_reopen_keeps_original_start() {
let mut store = store_with(1);
store.apply(
&[edit(1, TaskStatus::InProgress)],
Stamp {
now_epoch: 100,
run_tokens: 10,
},
);
store.apply(
&[edit(1, TaskStatus::Completed)],
Stamp {
now_epoch: 200,
run_tokens: 20,
},
);
store.apply(
&[edit(1, TaskStatus::InProgress)],
Stamp {
now_epoch: 300,
run_tokens: 30,
},
);
let task = &store.tasks[0];
assert_eq!(task.started_at, Some(100));
assert_eq!(task.completed_at, None);
assert_eq!(task.tokens_spent, None);
}
#[test]
fn evidence_ring_is_bounded_and_targets_active() {
let mut store = store_with(2);
assert!(!store.record_evidence(EvidenceEntry {
tool: "edit_file".into(),
target: "a.rs".into(),
status: "ok".into(),
}));
store.apply(&[edit(2, TaskStatus::InProgress)], Stamp::default());
for i in 0..(EVIDENCE_CAP + 5) {
assert!(store.record_evidence(EvidenceEntry {
tool: "execute_command".into(),
target: format!("cmd {i}"),
status: "ok".into(),
}));
}
let task = store.tasks.iter().find(|t| t.id == 2).unwrap();
assert_eq!(task.evidence.len(), EVIDENCE_CAP);
assert_eq!(task.evidence[0].target, "cmd 5");
}
#[test]
fn progress_and_active_and_next() {
let mut store = store_with(3);
store.apply(&[edit(1, TaskStatus::InProgress)], Stamp::default());
store.apply(
&[
edit(1, TaskStatus::Completed),
edit(2, TaskStatus::InProgress),
],
Stamp::default(),
);
assert_eq!(store.progress_string(), "Tasks 1/3");
assert_eq!(store.active().unwrap().id, 2);
assert_eq!(store.next_pending().unwrap().id, 3);
assert!(!store.all_done());
}
#[test]
fn newly_completed_diff() {
let mut store = store_with(2);
store.apply(&[edit(1, TaskStatus::InProgress)], Stamp::default());
let before = store.clone();
store.apply(&[edit(1, TaskStatus::Completed)], Stamp::default());
let fresh = store.newly_completed(&before);
assert_eq!(fresh.len(), 1);
assert_eq!(fresh[0].id, 1);
assert!(store.newly_completed(&store.clone()).is_empty());
}
#[test]
fn serde_roundtrip_and_legacy_defaults() {
let mut store = store_with(1);
store.apply(&[edit(1, TaskStatus::InProgress)], Stamp::default());
let json = serde_json::to_string(&store).unwrap();
let back: TaskStore = serde_json::from_str(&json).unwrap();
assert_eq!(store, back);
let legacy: TaskItem =
serde_json::from_str(r#"{"id":1,"subject":"s","active_form":"a","status":"pending"}"#)
.unwrap();
assert_eq!(legacy.origin, TaskOrigin::Model);
assert!(legacy.evidence.is_empty());
}
}