use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(from = "TaskDepDef", into = "TaskDepDef")]
pub enum TaskDep {
Index(usize),
Id(Uuid),
}
impl TaskDep {
pub fn to_uuid(&self, order_to_id: &std::collections::HashMap<usize, Uuid>) -> Option<Uuid> {
match self {
TaskDep::Id(uuid) => Some(*uuid),
TaskDep::Index(idx) => order_to_id.get(idx).copied(),
}
}
pub fn is_uuid(&self) -> bool {
matches!(self, TaskDep::Id(_))
}
pub fn as_uuid(&self) -> Option<Uuid> {
match self {
TaskDep::Id(uuid) => Some(*uuid),
TaskDep::Index(_) => None,
}
}
}
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum TaskDepDef {
Uuid(Uuid),
Index(usize),
}
impl From<TaskDepDef> for TaskDep {
fn from(def: TaskDepDef) -> Self {
match def {
TaskDepDef::Uuid(uuid) => TaskDep::Id(uuid),
TaskDepDef::Index(idx) => TaskDep::Index(idx),
}
}
}
impl From<TaskDep> for TaskDepDef {
fn from(dep: TaskDep) -> Self {
match dep {
TaskDep::Id(uuid) => TaskDepDef::Uuid(uuid),
TaskDep::Index(idx) => TaskDepDef::Index(idx),
}
}
}
pub fn deserialize_task_deps<'de, D>(deserializer: D) -> Result<Vec<TaskDep>, D::Error>
where
D: serde::Deserializer<'de>,
{
struct TaskDepVisitor;
impl<'de> serde::de::Visitor<'de> for TaskDepVisitor {
type Value = Vec<TaskDep>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("an array of task indices (1-based integers) or UUID strings")
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: serde::de::SeqAccess<'de>,
{
let mut deps = Vec::new();
while let Some(val) = seq.next_element::<serde_json::Value>()? {
match val {
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
if i < 1 {
return Err(serde::de::Error::custom("task indices start at 1"));
}
deps.push(TaskDep::Index(i as usize));
} else {
return Err(serde::de::Error::custom(
"task index must be a positive integer",
));
}
}
serde_json::Value::String(s) => match Uuid::parse_str(&s) {
Ok(uuid) => deps.push(TaskDep::Id(uuid)),
Err(_) => {
return Err(serde::de::Error::custom(format!("invalid UUID: {}", s)));
}
},
_ => {
return Err(serde::de::Error::custom(
"task dependency must be an integer index or UUID string",
));
}
}
}
Ok(deps)
}
}
deserializer.deserialize_seq(TaskDepVisitor)
}
pub fn serialize_task_type<S>(task_type: &TaskType, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let s = match task_type {
TaskType::Research => "research",
TaskType::Edit => "edit",
TaskType::Create => "create",
TaskType::Delete => "delete",
TaskType::Test => "test",
TaskType::Refactor => "refactor",
TaskType::Documentation => "documentation",
TaskType::Configuration => "configuration",
TaskType::Build => "build",
TaskType::Other(s) => s.as_str(),
};
serializer.serialize_str(s)
}
pub fn deserialize_task_type<'de, D>(deserializer: D) -> Result<TaskType, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
match s.to_lowercase().as_str() {
"research" => Ok(TaskType::Research),
"edit" => Ok(TaskType::Edit),
"create" => Ok(TaskType::Create),
"delete" => Ok(TaskType::Delete),
"test" => Ok(TaskType::Test),
"refactor" => Ok(TaskType::Refactor),
"documentation" => Ok(TaskType::Documentation),
"configuration" => Ok(TaskType::Configuration),
"build" => Ok(TaskType::Build),
"other" => Ok(TaskType::Other("other".to_string())),
unknown => {
tracing::debug!("Unknown task_type '{unknown}' mapped to the 'other' category");
Ok(TaskType::Other(unknown.to_string()))
}
}
}
fn default_uuid() -> Uuid {
Uuid::new_v4()
}
fn default_now() -> DateTime<Utc> {
Utc::now()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanDocument {
#[serde(default = "default_uuid")]
pub id: Uuid,
#[serde(default = "default_uuid")]
pub session_id: Uuid,
#[serde(default)]
pub title: String,
#[serde(default)]
pub description: String,
pub tasks: Vec<PlanTask>,
#[serde(default)]
pub status: PlanStatus,
#[serde(default = "default_now")]
pub created_at: DateTime<Utc>,
#[serde(default = "default_now")]
pub updated_at: DateTime<Utc>,
#[serde(default)]
pub approved_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub pre_init_editing: bool,
}
impl PlanDocument {
pub fn new(session_id: Uuid, title: String) -> Self {
Self {
id: Uuid::new_v4(),
session_id,
title,
description: String::new(),
tasks: Vec::new(),
status: PlanStatus::Editing,
created_at: Utc::now(),
updated_at: Utc::now(),
approved_at: None,
pre_init_editing: false,
}
}
pub fn add_task(&mut self, task: PlanTask) {
self.tasks.push(task);
self.updated_at = Utc::now();
}
pub fn resolve_index_deps(&mut self) {
use std::collections::HashMap;
let mut order_to_id: HashMap<usize, Uuid> = HashMap::new();
for (idx, task) in self.tasks.iter().enumerate() {
let order = if task.order > 0 { task.order } else { idx + 1 };
order_to_id.insert(order, task.id);
}
for task in &mut self.tasks {
let resolved: Vec<TaskDep> = task
.dependencies
.iter()
.map(|dep| {
match dep {
TaskDep::Index(idx) => {
if let Some(uuid) = order_to_id.get(idx) {
TaskDep::Id(*uuid)
} else {
TaskDep::Index(*idx)
}
}
TaskDep::Id(_) => dep.clone(),
}
})
.collect();
task.dependencies = resolved;
}
}
pub fn tasks_in_order(&self) -> Option<Vec<&PlanTask>> {
use std::collections::{HashMap, VecDeque};
let mut in_degree: HashMap<Uuid, usize> = HashMap::new();
let mut dependents: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
for task in &self.tasks {
let uuid_deps: Vec<Uuid> = task
.dependencies
.iter()
.filter_map(|d| d.as_uuid())
.collect();
in_degree.insert(task.id, uuid_deps.len());
for dep_id in &uuid_deps {
dependents.entry(*dep_id).or_default().push(task.id);
}
}
let mut queue: VecDeque<Uuid> = VecDeque::new();
for task in &self.tasks {
if task.dependencies.is_empty() {
queue.push_back(task.id);
}
}
let mut sorted_ids = Vec::new();
while let Some(task_id) = queue.pop_front() {
sorted_ids.push(task_id);
if let Some(deps) = dependents.get(&task_id) {
for &dependent_id in deps {
if let Some(degree) = in_degree.get_mut(&dependent_id) {
*degree -= 1;
if *degree == 0 {
queue.push_back(dependent_id);
}
}
}
}
}
if sorted_ids.len() != self.tasks.len() {
return None; }
let task_map: HashMap<Uuid, &PlanTask> = self.tasks.iter().map(|t| (t.id, t)).collect();
Some(
sorted_ids
.iter()
.filter_map(|id| task_map.get(id).copied())
.collect(),
)
}
pub fn get_task(&self, task_id: &Uuid) -> Option<&PlanTask> {
self.tasks.iter().find(|t| t.id == *task_id)
}
pub fn get_task_mut(&mut self, task_id: &Uuid) -> Option<&mut PlanTask> {
self.updated_at = Utc::now();
self.tasks.iter_mut().find(|t| t.id == *task_id)
}
pub fn count_by_status(&self, status: TaskStatus) -> usize {
self.tasks.iter().filter(|t| t.status == status).count()
}
pub fn progress_percentage(&self) -> f32 {
if self.tasks.is_empty() {
return 0.0;
}
let completed = self.count_by_status(TaskStatus::Completed);
(completed as f32 / self.tasks.len() as f32) * 100.0
}
pub fn is_complete(&self) -> bool {
!self.tasks.is_empty()
&& self
.tasks
.iter()
.all(|t| matches!(t.status, TaskStatus::Completed | TaskStatus::Skipped))
}
pub fn complete_trailing_delivery_task(&mut self) -> bool {
let Some(last) = self.tasks.len().checked_sub(1) else {
return false;
};
let head_resolved = self.tasks[..last]
.iter()
.all(|t| matches!(t.status, TaskStatus::Completed | TaskStatus::Skipped));
if head_resolved && matches!(self.tasks[last].status, TaskStatus::Pending) {
self.tasks[last].complete(Some(
"Auto-completed at turn settle: the turn delivered its final response (#737)."
.to_string(),
));
return true;
}
false
}
pub fn approve(&mut self) {
self.status = PlanStatus::Active;
self.approved_at = Some(Utc::now());
self.updated_at = Utc::now();
}
pub fn start_execution(&mut self) {
self.status = PlanStatus::Active;
self.updated_at = Utc::now();
}
pub fn validate_dependencies(&self) -> Result<(), String> {
let task_ids: std::collections::HashSet<Uuid> = self.tasks.iter().map(|t| t.id).collect();
for task in &self.tasks {
for dep in &task.dependencies {
if dep.as_uuid().is_some_and(|id| !task_ids.contains(&id)) {
return Err(format!(
"❌ Invalid Dependency\n\n\
Task '{}' (#{}) depends on a task that doesn't exist.\n\n\
💡 Fix: Remove this dependency or ensure the referenced task is added first.",
task.title, task.order
));
}
}
}
let ordered = self.tasks_in_order();
if ordered.is_none() {
let unprocessed: Vec<&str> = self
.tasks
.iter()
.filter(|task| !task.dependencies.is_empty())
.map(|task| task.title.as_str())
.collect();
return Err(format!(
"❌ Circular Dependency Detected\n\n\
Tasks with dependencies: {}\n\n\
💡 Fix: Review the dependency chain and remove circular references.\n\
Example: If Task A depends on B, B depends on C, and C depends on A,\n\
you need to break one of these dependency links.",
unprocessed.join(", ")
));
}
Ok(())
}
pub fn next_executable_task(&self) -> Option<&PlanTask> {
let completed_ids: std::collections::HashSet<Uuid> = self
.tasks
.iter()
.filter(|t| matches!(t.status, TaskStatus::Completed | TaskStatus::Skipped))
.map(|t| t.id)
.collect();
self.tasks.iter().find(|task| {
matches!(task.status, TaskStatus::Pending)
&& task
.dependencies
.iter()
.all(|dep| dep.as_uuid().is_some_and(|id| completed_ids.contains(&id)))
})
}
pub fn next_executable_task_mut(&mut self) -> Option<&mut PlanTask> {
let completed_ids: std::collections::HashSet<Uuid> = self
.tasks
.iter()
.filter(|t| matches!(t.status, TaskStatus::Completed | TaskStatus::Skipped))
.map(|t| t.id)
.collect();
self.updated_at = Utc::now();
self.tasks.iter_mut().find(|task| {
matches!(task.status, TaskStatus::Pending)
&& task
.dependencies
.iter()
.all(|dep| dep.as_uuid().is_some_and(|id| completed_ids.contains(&id)))
})
}
pub fn get_task_by_order(&self, order: usize) -> Option<&PlanTask> {
self.tasks.iter().find(|t| t.order == order)
}
pub fn get_task_by_order_mut(&mut self, order: usize) -> Option<&mut PlanTask> {
self.updated_at = Utc::now();
self.tasks.iter_mut().find(|t| t.order == order)
}
pub fn dependencies_satisfied(&self, task: &PlanTask) -> bool {
task.dependencies.iter().all(|dep| {
dep.as_uuid()
.and_then(|id| self.get_task(&id))
.map(|dep| matches!(dep.status, TaskStatus::Completed | TaskStatus::Skipped))
.unwrap_or(false)
})
}
pub fn ready_tasks(&self) -> Vec<&PlanTask> {
self.tasks
.iter()
.filter(|task| {
matches!(task.status, TaskStatus::Pending) && self.dependencies_satisfied(task)
})
.collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PlanStatus {
#[default]
Editing,
Active,
}
impl Serialize for PlanStatus {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(match self {
PlanStatus::Editing => "Editing",
PlanStatus::Active => "Active",
})
}
}
impl<'de> Deserialize<'de> for PlanStatus {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Ok(match s.as_str() {
"Active" | "Approved" | "InProgress" | "Completed" => PlanStatus::Active,
_ => PlanStatus::Editing,
})
}
}
impl std::fmt::Display for PlanStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PlanStatus::Editing => write!(f, "Editing"),
PlanStatus::Active => write!(f, "Active"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanTask {
#[serde(default = "default_uuid")]
pub id: Uuid,
#[serde(default)]
pub order: usize,
pub title: String,
pub description: String,
#[serde(
deserialize_with = "deserialize_task_type",
serialize_with = "serialize_task_type"
)]
pub task_type: TaskType,
#[serde(default, deserialize_with = "deserialize_task_deps")]
pub dependencies: Vec<TaskDep>,
#[serde(default)]
pub complexity: u8,
#[serde(default)]
pub acceptance_criteria: Vec<String>,
#[serde(default)]
pub status: TaskStatus,
#[serde(default)]
pub notes: Option<String>,
#[serde(default)]
pub retry_count: u8,
}
impl PlanTask {
pub fn new(order: usize, title: String, description: String, task_type: TaskType) -> Self {
Self {
id: Uuid::new_v4(),
order,
title,
description,
task_type,
dependencies: Vec::new(),
complexity: 3, acceptance_criteria: Vec::new(),
status: TaskStatus::Pending,
notes: None,
retry_count: 0,
}
}
pub fn start(&mut self) {
if matches!(self.status, TaskStatus::Failed) {
self.retry_count += 1;
}
self.status = TaskStatus::InProgress;
}
pub fn complete(&mut self, notes: Option<String>) {
self.status = TaskStatus::Completed;
self.notes = notes;
}
pub fn fail(&mut self, reason: String) {
self.status = TaskStatus::Failed;
self.notes = Some(reason);
}
pub fn block(&mut self, reason: String) {
self.status = TaskStatus::Blocked(reason);
}
pub fn skip(&mut self, reason: Option<String>) {
self.status = TaskStatus::Skipped;
if let Some(r) = reason {
self.notes = Some(r);
}
}
pub fn complexity_stars(&self) -> String {
let filled = self.complexity.min(5);
let empty = 5 - filled;
"★".repeat(filled as usize) + &"☆".repeat(empty as usize)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum TaskType {
Research,
Edit,
Create,
Delete,
Test,
Refactor,
Documentation,
Configuration,
Build,
Other(String),
}
impl std::fmt::Display for TaskType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TaskType::Research => write!(f, "Research"),
TaskType::Edit => write!(f, "Edit"),
TaskType::Create => write!(f, "Create"),
TaskType::Delete => write!(f, "Delete"),
TaskType::Test => write!(f, "Test"),
TaskType::Refactor => write!(f, "Refactor"),
TaskType::Documentation => write!(f, "Documentation"),
TaskType::Configuration => write!(f, "Configuration"),
TaskType::Build => write!(f, "Build"),
TaskType::Other(s) => write!(f, "{}", s),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum TaskStatus {
#[default]
Pending,
InProgress,
Completed,
Skipped,
Failed,
Blocked(String),
}
impl std::fmt::Display for TaskStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TaskStatus::Pending => write!(f, "Pending"),
TaskStatus::InProgress => write!(f, "In Progress"),
TaskStatus::Completed => write!(f, "Completed"),
TaskStatus::Skipped => write!(f, "Skipped"),
TaskStatus::Failed => write!(f, "Failed"),
TaskStatus::Blocked(reason) => write!(f, "Blocked: {}", reason),
}
}
}
impl TaskStatus {
pub fn icon(&self) -> &str {
match self {
TaskStatus::Pending => "⏸️",
TaskStatus::InProgress => "▶️",
TaskStatus::Completed => "✅",
TaskStatus::Skipped => "⏭️",
TaskStatus::Failed => "❌",
TaskStatus::Blocked(_) => "🚫",
}
}
}
pub fn is_verifiable(criterion: &str) -> bool {
let lower = criterion.to_lowercase();
const RUNNABLE: &[&str] = &[
"cargo", "git ", "gh ", "npm ", "pnpm ", "make ", "curl ", "pytest", "python", "node ",
"grep", "rg ", "sed ", "awk ", "docker", "psql", "sqlite3", "stat ", "ls ", "wc ",
];
let has_command = criterion.contains('`') || RUNNABLE.iter().any(|p| lower.contains(p));
const EXPECT_STEM: &[&str] = &[
"exit",
"pass",
"fail",
"return",
"print",
"output",
"show",
"report",
"match",
"contain",
"clean",
"green",
"no warning",
"0 error",
];
let stems: Vec<String> = lower
.split(|c: char| !c.is_ascii_alphanumeric())
.map(|w| w.trim_end_matches("es").trim_end_matches('s').to_string())
.collect();
let has_expectation = EXPECT_STEM
.iter()
.any(|p| stems.iter().any(|w| w == p) || lower.contains(p));
has_command && has_expectation
}
pub fn has_receipt(notes: &str) -> bool {
let lower = notes.to_lowercase();
let sha = notes
.split(|c: char| !c.is_ascii_alphanumeric())
.any(|w| w.len() >= 7 && w.len() <= 40 && w.chars().all(|c| c.is_ascii_hexdigit()));
let counted = lower.contains("passed") || lower.contains("exit 0") || lower.contains("failed");
let path = notes.contains('/') && notes.contains('.');
sha || counted || path
}
pub fn quality_glyph(task: &PlanTask) -> Option<&'static str> {
if task.acceptance_criteria.is_empty() {
return Some("⚠️");
}
if !task.acceptance_criteria.iter().any(|c| is_verifiable(c)) {
return Some("🔓");
}
if task.status == TaskStatus::Completed
&& !task.notes.as_deref().map(has_receipt).unwrap_or(false)
{
return Some("❔");
}
None
}
pub fn quality_glyph_suffix(task: &PlanTask) -> String {
quality_glyph(task)
.map(|g| format!(" {g}"))
.unwrap_or_default()
}