diff --git a/src/graph/dependency.rs b/src/graph/dependency.rs index 40ceb7c..43cc76e 100644 --- a/src/graph/dependency.rs +++ b/src/graph/dependency.rs @@ -1,2 +1,20 @@ -// Dependency resolution helpers (to be implemented in subsequent tasks) -// This module will contain helpers for resolving dependencies between nodes. +/// Check if all dependencies for a node are satisfied (completed) +/// +/// Queries all DependsOn edges FROM the given node_id and checks if all +/// target nodes have status 'completed'. Returns true if all dependencies are met, +/// or if the node has no dependencies. +pub fn check_dependencies_met( + conn: &rusqlite::Connection, + node_id: &str, +) -> rusqlite::Result { + // Count how many DependsOn edges FROM this node point to non-completed nodes + let unmet_deps_count: u32 = conn.query_row( + "SELECT COUNT(*) FROM edges e + JOIN nodes n ON e.to_node = n.id + WHERE e.from_node = ?1 AND e.edge_type = 'depends_on' AND n.status != 'completed'", + rusqlite::params![node_id], + |row| row.get(0), + )?; + + Ok(unmet_deps_count == 0) +} diff --git a/tests/graph_dependency_test.rs b/tests/graph_dependency_test.rs new file mode 100644 index 0000000..12d5c1e --- /dev/null +++ b/tests/graph_dependency_test.rs @@ -0,0 +1,351 @@ +use anyhow::Result; +use chrono::Utc; +use rustagent::db::Database; +use rustagent::graph::store::{GraphStore, SqliteGraphStore}; +use rustagent::graph::*; +use std::collections::HashMap; + +/// Helper to create a test goal node +fn create_test_goal(id: &str, project_id: &str, title: &str) -> GraphNode { + GraphNode { + id: id.to_string(), + project_id: project_id.to_string(), + node_type: NodeType::Goal, + title: title.to_string(), + description: "Test goal".to_string(), + status: NodeStatus::Pending, + priority: Some(Priority::High), + assigned_to: None, + created_by: None, + labels: vec![], + created_at: Utc::now(), + started_at: None, + completed_at: None, + blocked_reason: None, + metadata: HashMap::new(), + } +} + +/// Helper to create a test task node +fn create_test_task( + id: &str, + project_id: &str, + title: &str, + status: NodeStatus, + priority: Option, +) -> GraphNode { + GraphNode { + id: id.to_string(), + project_id: project_id.to_string(), + node_type: NodeType::Task, + title: title.to_string(), + description: "Test task".to_string(), + status, + priority, + assigned_to: None, + created_by: None, + labels: vec![], + created_at: Utc::now(), + started_at: None, + completed_at: None, + blocked_reason: None, + metadata: HashMap::new(), + } +} + +/// Helper to set up a test database with a project +async fn setup_test_env() -> Result<(Database, SqliteGraphStore)> { + let db = Database::open_in_memory().await?; + let graph_store = SqliteGraphStore::new(db.clone()); + + // Create a test project by directly inserting into the database + let db_for_project = db.clone(); + db_for_project + .connection() + .call(|conn| { + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO projects (id, name, path, registered_at, config_overrides, metadata) + VALUES (?, ?, ?, ?, ?, ?)", + rusqlite::params![ + "proj-1", + "proj-1", + "/tmp/proj-1", + &now, + None::, + "{}" + ], + )?; + Ok(()) + }) + .await?; + + Ok((db, graph_store)) +} + +/// P1b.AC4.1: Task moves from Pending to Ready when all DependsOn targets are Completed +#[tokio::test] +async fn test_task_pending_to_ready_when_deps_complete() -> Result<()> { + let (_db, store) = setup_test_env().await?; + + // Create a goal and two tasks + let goal = create_test_goal("ra-a1b2", "proj-1", "Test Goal"); + let task_a = create_test_task( + "ra-a1b2.1", + "proj-1", + "Task A", + NodeStatus::Pending, + Some(Priority::Medium), + ); + let task_b = create_test_task( + "ra-a1b2.2", + "proj-1", + "Task B", + NodeStatus::Pending, + Some(Priority::Medium), + ); + + store.create_node(&goal).await?; + store.create_node(&task_a).await?; + store.create_node(&task_b).await?; + + // Create a DependsOn edge: Task B depends on Task A + let edge = GraphEdge { + id: generate_edge_id(), + edge_type: EdgeType::DependsOn, + from_node: "ra-a1b2.2".to_string(), + to_node: "ra-a1b2.1".to_string(), + label: None, + created_at: Utc::now(), + }; + store.add_edge(&edge).await?; + + // Initially, Task B should still be Pending + let task_b_before = store.get_node("ra-a1b2.2").await?; + assert!(task_b_before.is_some()); + assert_eq!(task_b_before.unwrap().status, NodeStatus::Pending); + + // Complete Task A + store + .update_node("ra-a1b2.1", Some(NodeStatus::Completed), None, None, None) + .await?; + + // Now Task B should be Ready (automatically promoted by the status transition hook) + let task_b_after = store.get_node("ra-a1b2.2").await?; + assert!(task_b_after.is_some()); + let task_b_node = task_b_after.unwrap(); + assert_eq!(task_b_node.status, NodeStatus::Ready); + + Ok(()) +} + +/// P1b.AC4.2: get_ready_tasks returns only tasks in Ready status with all deps satisfied +#[tokio::test] +async fn test_get_ready_tasks_filters_correctly() -> Result<()> { + let (_db, store) = setup_test_env().await?; + + // Create a goal + let goal = create_test_goal("ra-a1b2", "proj-1", "Test Goal"); + store.create_node(&goal).await?; + + // Create three tasks: one Ready, one Pending (with unmet deps), one Completed + let task_ready = create_test_task( + "ra-a1b2.1", + "proj-1", + "Task Ready", + NodeStatus::Ready, + Some(Priority::Medium), + ); + let task_pending = create_test_task( + "ra-a1b2.2", + "proj-1", + "Task Pending", + NodeStatus::Pending, + Some(Priority::Medium), + ); + let task_completed = create_test_task( + "ra-a1b2.3", + "proj-1", + "Task Completed", + NodeStatus::Completed, + Some(Priority::Medium), + ); + + store.create_node(&task_ready).await?; + store.create_node(&task_pending).await?; + store.create_node(&task_completed).await?; + + // Create a DependsOn edge: Task Pending depends on Task Completed + let edge = GraphEdge { + id: generate_edge_id(), + edge_type: EdgeType::DependsOn, + from_node: "ra-a1b2.2".to_string(), + to_node: "ra-a1b2.3".to_string(), + label: None, + created_at: Utc::now(), + }; + store.add_edge(&edge).await?; + + // get_ready_tasks should return only the one Ready task + let ready_tasks = store.get_ready_tasks("ra-a1b2").await?; + assert_eq!(ready_tasks.len(), 1); + assert_eq!(ready_tasks[0].id, "ra-a1b2.1"); + assert_eq!(ready_tasks[0].status, NodeStatus::Ready); + + Ok(()) +} + +/// P1b.AC4.3: get_next_task returns highest-priority Ready task, breaking ties by downstream unblock count +#[tokio::test] +async fn test_get_next_task_priority_and_downstream() -> Result<()> { + let (_db, store) = setup_test_env().await?; + + // Create a goal + let goal = create_test_goal("ra-a1b2", "proj-1", "Test Goal"); + store.create_node(&goal).await?; + + // Create two ready tasks: one High priority (blocking 3 tasks), one Critical priority (blocking 0) + let task_high_priority = create_test_task( + "ra-a1b2.1", + "proj-1", + "High Priority", + NodeStatus::Ready, + Some(Priority::High), + ); + let task_critical_priority = create_test_task( + "ra-a1b2.2", + "proj-1", + "Critical Priority", + NodeStatus::Ready, + Some(Priority::Critical), + ); + + store.create_node(&task_high_priority).await?; + store.create_node(&task_critical_priority).await?; + + // Create 3 more tasks that depend on the High priority task + let dependent1 = create_test_task( + "ra-a1b2.3", + "proj-1", + "Dependent 1", + NodeStatus::Pending, + Some(Priority::Medium), + ); + let dependent2 = create_test_task( + "ra-a1b2.4", + "proj-1", + "Dependent 2", + NodeStatus::Pending, + Some(Priority::Medium), + ); + let dependent3 = create_test_task( + "ra-a1b2.5", + "proj-1", + "Dependent 3", + NodeStatus::Pending, + Some(Priority::Medium), + ); + + store.create_node(&dependent1).await?; + store.create_node(&dependent2).await?; + store.create_node(&dependent3).await?; + + // Create DependsOn edges from the three dependents to the high priority task + for i in 3..=5 { + let edge = GraphEdge { + id: generate_edge_id(), + edge_type: EdgeType::DependsOn, + from_node: format!("ra-a1b2.{}", i), + to_node: "ra-a1b2.1".to_string(), + label: None, + created_at: Utc::now(), + }; + store.add_edge(&edge).await?; + } + + // get_next_task should return the Critical priority task (priority wins over downstream count) + let next_task = store.get_next_task("ra-a1b2").await?; + assert!(next_task.is_some()); + let task = next_task.unwrap(); + assert_eq!(task.id, "ra-a1b2.2"); + assert_eq!(task.priority, Some(Priority::Critical)); + + Ok(()) +} + +/// P1b.AC4.3 variant: When priorities are equal, downstream count should be the tiebreaker +#[tokio::test] +async fn test_get_next_task_tiebreak_by_downstream() -> Result<()> { + let (_db, store) = setup_test_env().await?; + + // Create a goal + let goal = create_test_goal("ra-a1b2", "proj-1", "Test Goal"); + store.create_node(&goal).await?; + + // Create two ready tasks with the same priority + let task_a = create_test_task( + "ra-a1b2.1", + "proj-1", + "Task A", + NodeStatus::Ready, + Some(Priority::High), + ); + let task_b = create_test_task( + "ra-a1b2.2", + "proj-1", + "Task B", + NodeStatus::Ready, + Some(Priority::High), + ); + + store.create_node(&task_a).await?; + store.create_node(&task_b).await?; + + // Create 3 tasks that depend on Task A (higher downstream count) + let dep_a1 = create_test_task( + "ra-a1b2.3", + "proj-1", + "Dep A1", + NodeStatus::Pending, + Some(Priority::Medium), + ); + let dep_a2 = create_test_task( + "ra-a1b2.4", + "proj-1", + "Dep A2", + NodeStatus::Pending, + Some(Priority::Medium), + ); + let dep_a3 = create_test_task( + "ra-a1b2.5", + "proj-1", + "Dep A3", + NodeStatus::Pending, + Some(Priority::Medium), + ); + + store.create_node(&dep_a1).await?; + store.create_node(&dep_a2).await?; + store.create_node(&dep_a3).await?; + + // Create edges: 3 tasks depend on A, 0 on B + for i in 3..=5 { + let edge = GraphEdge { + id: generate_edge_id(), + edge_type: EdgeType::DependsOn, + from_node: format!("ra-a1b2.{}", i), + to_node: "ra-a1b2.1".to_string(), + label: None, + created_at: Utc::now(), + }; + store.add_edge(&edge).await?; + } + + // get_next_task should return Task A (higher downstream count) + let next_task = store.get_next_task("ra-a1b2").await?; + assert!(next_task.is_some()); + let task = next_task.unwrap(); + assert_eq!(task.id, "ra-a1b2.1"); + + Ok(()) +}