pe-tasks 0.1.0

Task management for Potential Expectations — structured work items, dependency DAG, lifecycle hooks, and agent tools
Documentation
//! # Core task types — the structural primitives.
//!
//! A `Task` is a typed work item that agents create, track, decompose, and
//! depend on. The library provides the structure; users build policies on top.

use std::collections::HashMap;

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

/// Unique identifier for a task. Re-exported from pe-core for consistency.
pub use pe_core::scope::TaskId;

/// Generate a new unique task ID.
#[must_use]
pub fn new_task_id() -> TaskId {
    uuid::Uuid::new_v4().to_string()
}

/// A structured work item.
///
/// Tasks are the atomic unit of work in the agent system. They can be
/// organized hierarchically (parent/child via `parent_task_id`), linked
/// via dependencies (see [`TaskDependency`](crate::dependency::TaskDependency)),
/// and tracked through lifecycle transitions.
///
/// # Example
///
/// ```
/// use pe_tasks::Task;
///
/// let task = Task::new("Implement login page");
/// assert_eq!(task.status, pe_tasks::TaskStatus::Pending);
/// assert_eq!(task.priority, pe_tasks::TaskPriority::Medium);
/// ```
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Task {
    /// Unique identifier.
    pub id: TaskId,
    /// Discriminator: who/what created this task.
    pub task_type: TaskType,

    // --- Content ---
    /// Short title (what needs to be done).
    pub title: String,
    /// Detailed description (how/why).
    pub description: String,
    /// Current status in the lifecycle.
    pub status: TaskStatus,
    /// Priority level (1=urgent, 4=low).
    pub priority: TaskPriority,
    /// Freeform tags for classification.
    pub tags: Vec<String>,

    // --- Hierarchy ---
    /// Parent task ID for subtask relationships. None = root task.
    pub parent_task_id: Option<TaskId>,

    // --- Ownership ---
    /// Agent that created or owns this task. None for human-created tasks.
    pub agent_id: Option<String>,
    /// Who created this task: "user", "system", or an agent ID.
    pub created_by: String,
    /// Who is responsible: "user" or an agent ID.
    pub assignee: String,

    // --- Result ---
    /// Task output (populated on completion).
    pub result: Option<serde_json::Value>,
    /// Error message (populated on failure).
    pub error: Option<String>,

    // --- Metadata ---
    /// Extensible key-value metadata. Users add app-specific fields here
    /// (calendar dates, kanban columns, time tracking, etc.).
    pub metadata: HashMap<String, serde_json::Value>,

    // --- Lifecycle timestamps ---
    pub created_at: DateTime<Utc>,
    pub updated_at: Option<DateTime<Utc>>,
    pub completed_at: Option<DateTime<Utc>>,
    /// Soft delete timestamp. None = active. Some = in trash.
    pub deleted_at: Option<DateTime<Utc>>,
}

impl Task {
    /// Create a new task with a title. Defaults: pending, medium priority, human-created.
    #[must_use]
    pub fn new(title: impl Into<String>) -> Self {
        Self {
            id: new_task_id(),
            task_type: TaskType::Human,
            title: title.into(),
            description: String::new(),
            status: TaskStatus::Pending,
            priority: TaskPriority::Medium,
            tags: Vec::new(),
            parent_task_id: None,
            agent_id: None,
            created_by: "user".into(),
            assignee: "user".into(),
            result: None,
            error: None,
            metadata: HashMap::new(),
            created_at: Utc::now(),
            updated_at: None,
            completed_at: None,
            deleted_at: None,
        }
    }

    /// Create an agent-owned task.
    #[must_use]
    pub fn agent_task(title: impl Into<String>, agent_id: impl Into<String>) -> Self {
        let aid = agent_id.into();
        Self {
            task_type: TaskType::Agent,
            agent_id: Some(aid.clone()),
            created_by: aid.clone(),
            assignee: aid,
            ..Self::new(title)
        }
    }

    /// Create a plan root task (container for subtasks).
    #[must_use]
    pub fn plan(title: impl Into<String>) -> Self {
        Self {
            task_type: TaskType::Plan,
            created_by: "system".into(),
            assignee: "system".into(),
            ..Self::new(title)
        }
    }

    /// Whether this task has been soft-deleted.
    #[must_use]
    pub fn is_deleted(&self) -> bool {
        self.deleted_at.is_some()
    }

    /// Whether this task is in a terminal state (completed or cancelled).
    #[must_use]
    pub fn is_terminal(&self) -> bool {
        matches!(self.status, TaskStatus::Completed | TaskStatus::Cancelled)
    }

    /// Builder: set description.
    #[must_use]
    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
        self.description = desc.into();
        self
    }

    /// Builder: set priority.
    #[must_use]
    pub fn with_priority(mut self, priority: TaskPriority) -> Self {
        self.priority = priority;
        self
    }

    /// Builder: set parent task.
    #[must_use]
    pub fn with_parent(mut self, parent_id: impl Into<TaskId>) -> Self {
        self.parent_task_id = Some(parent_id.into());
        self
    }

    /// Builder: add tags.
    #[must_use]
    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
        self.tags = tags;
        self
    }
}

/// Task lifecycle status.
///
/// Valid transitions are enforced by [`TaskLifecycle`](crate::lifecycle::TaskLifecycle).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum TaskStatus {
    /// Not yet started.
    Pending,
    /// Work is actively happening.
    InProgress,
    /// Successfully finished.
    Completed,
    /// Failed with an error.
    Failed,
    /// Waiting on a dependency.
    Blocked,
    /// Explicitly abandoned.
    Cancelled,
}

/// What created this task.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum TaskType {
    /// Created by framework internals.
    System,
    /// Created by an agent during execution.
    Agent,
    /// Created by a human user.
    Human,
    /// Root of a multi-step plan (container for subtasks).
    Plan,
}

/// Priority level (lower number = more urgent).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
#[derive(Default)]
pub enum TaskPriority {
    /// Critical, do immediately.
    Urgent = 1,
    /// Important, do soon.
    High = 2,
    /// Normal priority.
    #[default]
    Medium = 3,
    /// Do when convenient.
    Low = 4,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_new_task_defaults() {
        let t = Task::new("Build feature");
        assert_eq!(t.title, "Build feature");
        assert_eq!(t.status, TaskStatus::Pending);
        assert_eq!(t.priority, TaskPriority::Medium);
        assert_eq!(t.task_type, TaskType::Human);
        assert_eq!(t.created_by, "user");
        assert_eq!(t.assignee, "user");
        assert!(t.parent_task_id.is_none());
        assert!(!t.id.is_empty());
    }

    #[test]
    fn test_agent_task() {
        let t = Task::agent_task("Research APIs", "agent-1");
        assert_eq!(t.task_type, TaskType::Agent);
        assert_eq!(t.agent_id.as_deref(), Some("agent-1"));
        assert_eq!(t.created_by, "agent-1");
        assert_eq!(t.assignee, "agent-1");
    }

    #[test]
    fn test_plan_task() {
        let t = Task::plan("Deploy v2.0");
        assert_eq!(t.task_type, TaskType::Plan);
        assert_eq!(t.created_by, "system");
    }

    #[test]
    fn test_builder_chain() {
        let t = Task::new("Fix bug")
            .with_description("The login form crashes")
            .with_priority(TaskPriority::High)
            .with_tags(vec!["bug".into(), "login".into()]);
        assert_eq!(t.description, "The login form crashes");
        assert_eq!(t.priority, TaskPriority::High);
        assert_eq!(t.tags.len(), 2);
    }

    #[test]
    fn test_subtask_with_parent() {
        let parent = Task::plan("Big project");
        let child = Task::new("Step 1").with_parent(&parent.id);
        assert_eq!(child.parent_task_id.as_deref(), Some(parent.id.as_str()));
    }

    #[test]
    fn test_serde_roundtrip() {
        let t = Task::new("Test task");
        let json = serde_json::to_string(&t).unwrap();
        let t2: Task = serde_json::from_str(&json).unwrap();
        assert_eq!(t.id, t2.id);
        assert_eq!(t.title, t2.title);
    }

    #[test]
    fn test_is_terminal() {
        let mut t = Task::new("Done");
        assert!(!t.is_terminal());
        t.status = TaskStatus::Completed;
        assert!(t.is_terminal());
        t.status = TaskStatus::Cancelled;
        assert!(t.is_terminal());
        t.status = TaskStatus::Failed;
        assert!(!t.is_terminal()); // failed is NOT terminal — can retry
    }
}