use serde::{Deserialize, Serialize};
use crate::harness::ids::next_seq;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskCardStatus {
Todo,
AwaitingApproval,
Ready,
InProgress,
Blocked,
Done,
Rejected,
}
impl TaskCardStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Todo => "todo",
Self::AwaitingApproval => "awaiting_approval",
Self::Ready => "ready",
Self::InProgress => "in_progress",
Self::Blocked => "blocked",
Self::Done => "done",
Self::Rejected => "rejected",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskApprovalMode {
Required,
NotRequired,
}
impl TaskApprovalMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Required => "required",
Self::NotRequired => "not_required",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskBoardCard {
pub id: String,
pub title: String,
pub status: TaskCardStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub objective: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub plan: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assigned_agent: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_tools: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approval_mode: Option<TaskApprovalMode>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub acceptance_criteria: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub evidence: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blocker: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_thread_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_metadata: Option<serde_json::Value>,
#[serde(default)]
pub order: u32,
#[serde(default)]
pub updated_at: String,
}
impl TaskBoardCard {
pub fn new(title: impl Into<String>) -> Self {
Self {
id: new_card_id(),
title: title.into(),
status: TaskCardStatus::Todo,
objective: None,
plan: Vec::new(),
assigned_agent: None,
allowed_tools: Vec::new(),
approval_mode: None,
acceptance_criteria: Vec::new(),
evidence: Vec::new(),
notes: None,
blocker: None,
session_thread_id: None,
source_metadata: None,
order: 0,
updated_at: now_stamp(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskBoard {
pub thread_id: String,
pub cards: Vec<TaskBoardCard>,
pub updated_at: String,
}
impl TaskBoard {
pub fn empty(thread_id: impl Into<String>) -> Self {
Self {
thread_id: thread_id.into(),
cards: Vec::new(),
updated_at: now_stamp(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TodosSnapshot {
pub thread_id: String,
pub cards: Vec<TaskBoardCard>,
pub markdown: String,
}
#[derive(Debug, Default, Clone)]
pub struct CardPatch {
pub content: Option<String>,
pub status: Option<TaskCardStatus>,
pub objective: Option<String>,
pub plan: Option<Vec<String>>,
pub assigned_agent: Option<String>,
pub allowed_tools: Option<Vec<String>>,
pub approval_mode: Option<Option<TaskApprovalMode>>,
pub acceptance_criteria: Option<Vec<String>>,
pub evidence: Option<Vec<String>>,
pub notes: Option<String>,
pub blocker: Option<String>,
pub source_metadata: Option<serde_json::Value>,
}
pub fn parse_status(raw: &str) -> Result<TaskCardStatus, String> {
match raw.trim().to_ascii_lowercase().as_str() {
"todo" | "pending" => Ok(TaskCardStatus::Todo),
"awaiting_approval" | "awaiting-approval" => Ok(TaskCardStatus::AwaitingApproval),
"ready" | "approved" => Ok(TaskCardStatus::Ready),
"in_progress" | "in-progress" | "inprogress" | "started" => Ok(TaskCardStatus::InProgress),
"blocked" => Ok(TaskCardStatus::Blocked),
"done" | "completed" | "complete" => Ok(TaskCardStatus::Done),
"rejected" | "denied" => Ok(TaskCardStatus::Rejected),
other => Err(format!(
"invalid status '{other}' (expected todo|awaiting_approval|ready|in_progress|blocked|done|rejected)"
)),
}
}
pub fn render_markdown(cards: &[TaskBoardCard]) -> String {
if cards.is_empty() {
return "_No todos yet._".to_string();
}
let mut out = String::new();
for card in cards {
let marker = match card.status {
TaskCardStatus::Todo | TaskCardStatus::Ready => "[ ]",
TaskCardStatus::AwaitingApproval => "[?]",
TaskCardStatus::InProgress => "[~]",
TaskCardStatus::Blocked => "[!]",
TaskCardStatus::Done => "[x]",
TaskCardStatus::Rejected => "[-]",
};
out.push_str("- ");
out.push_str(marker);
out.push(' ');
out.push_str(&card.title);
out.push_str(&format!(" `({})`", card.id));
out.push('\n');
if let Some(objective) = card.objective.as_deref() {
out.push_str(" - objective: ");
out.push_str(objective);
out.push('\n');
}
if let Some(agent) = card.assigned_agent.as_deref() {
out.push_str(" - agent: ");
out.push_str(agent);
out.push('\n');
}
if !card.allowed_tools.is_empty() {
out.push_str(" - tools: ");
out.push_str(&card.allowed_tools.join(", "));
out.push('\n');
}
if let Some(mode) = card.approval_mode.as_ref() {
out.push_str(" - approval: ");
out.push_str(mode.as_str());
out.push('\n');
}
if !card.plan.is_empty() {
out.push_str(" - plan:\n");
for step in &card.plan {
out.push_str(" - ");
out.push_str(step);
out.push('\n');
}
}
if !card.acceptance_criteria.is_empty() {
out.push_str(" - acceptance criteria:\n");
for criterion in &card.acceptance_criteria {
out.push_str(" - ");
out.push_str(criterion);
out.push('\n');
}
}
if !card.evidence.is_empty() {
out.push_str(" - evidence:\n");
for item in &card.evidence {
out.push_str(" - ");
out.push_str(item);
out.push('\n');
}
}
if matches!(card.status, TaskCardStatus::Blocked) {
if let Some(reason) = card.blocker.as_deref().or(card.notes.as_deref()) {
out.push_str(" - _blocked:_ ");
out.push_str(reason);
out.push('\n');
}
} else if let Some(notes) = card.notes.as_deref() {
out.push_str(" - ");
out.push_str(notes);
out.push('\n');
}
}
out.trim_end().to_string()
}
pub fn normalise_board(board: &mut TaskBoard) {
let now = now_stamp();
board.thread_id = board.thread_id.trim().to_string();
board.updated_at = now.clone();
for card in board.cards.iter_mut() {
card.title = card.title.trim().to_string();
if card.id.trim().is_empty() {
card.id = new_card_id();
} else {
card.id = card.id.trim().to_string();
}
card.notes = trim_opt(card.notes.take());
card.objective = trim_opt(card.objective.take());
card.assigned_agent = trim_opt(card.assigned_agent.take());
trim_string_vec(&mut card.plan);
trim_string_vec(&mut card.allowed_tools);
trim_string_vec(&mut card.acceptance_criteria);
trim_string_vec(&mut card.evidence);
card.blocker = trim_opt(card.blocker.take());
card.session_thread_id = trim_opt(card.session_thread_id.take());
if card.status == TaskCardStatus::Blocked && card.blocker.is_none() {
card.blocker = card.notes.clone();
}
}
board.cards.retain(|card| !card.title.is_empty());
for (idx, card) in board.cards.iter_mut().enumerate() {
card.order = idx as u32;
card.updated_at = now.clone();
}
}
pub(crate) fn non_empty(s: String) -> Option<String> {
let trimmed = s.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn trim_opt(value: Option<String>) -> Option<String> {
value.and_then(non_empty)
}
fn trim_string_vec(values: &mut Vec<String>) {
values.retain_mut(|value| {
*value = value.trim().to_string();
!value.is_empty()
});
}
pub(crate) fn new_card_id() -> String {
format!("task-{}", next_seq())
}
pub(crate) fn now_stamp() -> String {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
.to_string()
}