use crate::db::models::{
DoneTaskResponse, NextStepSuggestion, PaginatedTasks, PickNextResponse, Task, TaskSortBy,
TaskWithEvents, WorkspaceStatus,
};
use crate::error::{IntentError, Result};
use crate::tasks::TaskUpdate;
use chrono::{DateTime, Utc};
use neo4rs::{query, Graph};
pub struct Neo4jTaskManager {
graph: Graph,
project_id: String,
}
impl Neo4jTaskManager {
pub fn new(graph: Graph, project_id: String) -> Self {
Self { graph, project_id }
}
pub async fn get_root_tasks(&self) -> Result<Vec<Task>> {
let mut result = self
.graph
.execute(
query(
"MATCH (t:Task {project_id: $pid}) \
WHERE NOT (t)-[:CHILD_OF]->() AND t.deleted_at IS NULL \
RETURN t \
ORDER BY \
CASE t.status \
WHEN 'doing' THEN 0 \
WHEN 'todo' THEN 1 \
WHEN 'done' THEN 2 \
END, \
t.priority ASC, \
t.id ASC",
)
.param("pid", self.project_id.clone()),
)
.await
.map_err(|e| neo4j_err("get_root_tasks", e))?;
let mut tasks = Vec::new();
while let Some(row) = result
.next()
.await
.map_err(|e| neo4j_err("get_root_tasks iterate", e))?
{
tasks.push(row_to_task(&row, "t")?);
}
Ok(tasks)
}
pub async fn get_task(&self, task_id: i64) -> Result<Task> {
let mut result = self
.graph
.execute(
query(
"MATCH (t:Task {project_id: $pid, id: $id}) \
WHERE t.deleted_at IS NULL RETURN t",
)
.param("pid", self.project_id.clone())
.param("id", task_id),
)
.await
.map_err(|e| neo4j_err("get_task query", e))?;
match result
.next()
.await
.map_err(|e| neo4j_err("get_task fetch", e))?
{
Some(row) => {
let node: neo4rs::Node = row.get("t").map_err(|e| neo4j_err("get_task node", e))?;
node_to_task(&node)
},
None => Err(IntentError::TaskNotFound(task_id)),
}
}
pub async fn get_task_ancestry(&self, task_id: i64) -> Result<Vec<Task>> {
let mut result = self
.graph
.execute(
query(
"MATCH path = (t:Task {project_id: $pid, id: $id})-[:CHILD_OF*1..]->(ancestor:Task {project_id: $pid}) \
RETURN ancestor, length(path) AS depth \
ORDER BY depth ASC",
)
.param("pid", self.project_id.clone())
.param("id", task_id),
)
.await
.map_err(|e| neo4j_err("get_task_ancestry", e))?;
let mut ancestors = Vec::new();
while let Some(row) = result
.next()
.await
.map_err(|e| neo4j_err("get_task_ancestry iterate", e))?
{
ancestors.push(row_to_task(&row, "ancestor")?);
}
Ok(ancestors)
}
pub async fn get_siblings(&self, task_id: i64, parent_id: Option<i64>) -> Result<Vec<Task>> {
let mut result = match parent_id {
Some(pid) => {
self.graph
.execute(
query(
"MATCH (t:Task {project_id: $proj})-[:CHILD_OF]->(parent:Task {project_id: $proj, id: $parent_id}) \
WHERE t.id <> $id \
RETURN t ORDER BY t.id ASC",
)
.param("proj", self.project_id.clone())
.param("parent_id", pid)
.param("id", task_id),
)
.await
.map_err(|e| neo4j_err("get_siblings (with parent)", e))?
}
None => {
self.graph
.execute(
query(
"MATCH (t:Task {project_id: $pid}) \
WHERE NOT (t)-[:CHILD_OF]->() AND t.id <> $id \
RETURN t ORDER BY t.id ASC",
)
.param("pid", self.project_id.clone())
.param("id", task_id),
)
.await
.map_err(|e| neo4j_err("get_siblings (root)", e))?
}
};
let mut tasks = Vec::new();
while let Some(row) = result
.next()
.await
.map_err(|e| neo4j_err("get_siblings iterate", e))?
{
tasks.push(row_to_task(&row, "t")?);
}
Ok(tasks)
}
pub async fn get_children(&self, task_id: i64) -> Result<Vec<Task>> {
let mut result = self
.graph
.execute(
query(
"MATCH (child:Task {project_id: $pid})-[:CHILD_OF]->(t:Task {project_id: $pid, id: $id}) \
RETURN child ORDER BY child.id ASC",
)
.param("pid", self.project_id.clone())
.param("id", task_id),
)
.await
.map_err(|e| neo4j_err("get_children", e))?;
let mut tasks = Vec::new();
while let Some(row) = result
.next()
.await
.map_err(|e| neo4j_err("get_children iterate", e))?
{
tasks.push(row_to_task(&row, "child")?);
}
Ok(tasks)
}
pub async fn get_descendants(&self, task_id: i64) -> Result<Vec<Task>> {
let mut result = self
.graph
.execute(
query(
"MATCH (desc:Task {project_id: $pid})-[:CHILD_OF*1..]->(t:Task {project_id: $pid, id: $id}) \
RETURN desc ORDER BY desc.id ASC",
)
.param("pid", self.project_id.clone())
.param("id", task_id),
)
.await
.map_err(|e| neo4j_err("get_descendants", e))?;
let mut descendants = Vec::new();
while let Some(row) = result
.next()
.await
.map_err(|e| neo4j_err("get_descendants iterate", e))?
{
descendants.push(row_to_task(&row, "desc")?);
}
Ok(descendants)
}
pub async fn get_status(
&self,
task_id: i64,
with_events: bool,
) -> Result<crate::db::models::StatusResponse> {
use crate::db::models::{StatusResponse, TaskBrief};
let task = self.get_task(task_id).await?;
let ancestors = self.get_task_ancestry(task_id).await?;
let siblings_full = self.get_siblings(task_id, task.parent_id).await?;
let descendants_full = self.get_descendants(task_id).await?;
let siblings: Vec<TaskBrief> = siblings_full.iter().map(TaskBrief::from).collect();
let descendants: Vec<TaskBrief> = descendants_full.iter().map(TaskBrief::from).collect();
let events = if with_events {
let event_mgr =
super::Neo4jEventManager::new(self.graph.clone(), self.project_id.clone());
let evts = event_mgr
.list_events(Some(task_id), Some(20), None, None)
.await?;
if evts.is_empty() {
None
} else {
Some(evts)
}
} else {
None
};
Ok(StatusResponse {
focused_task: task,
ancestors,
siblings,
descendants,
events,
})
}
pub async fn add_task(
&self,
name: String,
spec: Option<String>,
parent_id: Option<i64>,
owner: Option<String>,
priority: Option<i32>,
metadata: Option<String>,
) -> Result<Task> {
if let Some(pid) = parent_id {
self.check_task_exists(pid).await?;
}
let id = super::next_id(&self.graph, &self.project_id, "task").await?;
let now = Utc::now().to_rfc3339();
let owner = owner.as_deref().unwrap_or("human").to_string();
let mut cypher = String::from(
"CREATE (t:Task {project_id: $pid, id: $id, name: $name, \
status: 'todo', owner: $owner, first_todo_at: $now})",
);
let mut sets = Vec::new();
if spec.is_some() {
sets.push("t.spec = $spec");
}
if parent_id.is_some() {
sets.push("t.parent_id = $parent_id");
}
if priority.is_some() {
sets.push("t.priority = $priority");
}
if metadata.is_some() {
sets.push("t.metadata = $metadata");
}
if !sets.is_empty() {
cypher.push_str(" SET ");
cypher.push_str(&sets.join(", "));
}
if parent_id.is_some() {
cypher.push_str(
" WITH t \
MATCH (parent:Task {project_id: $pid, id: $parent_id}) \
CREATE (t)-[:CHILD_OF]->(parent)",
);
}
cypher.push_str(" RETURN t");
let mut q = query(&cypher)
.param("pid", self.project_id.clone())
.param("id", id)
.param("name", name)
.param("owner", owner)
.param("now", now);
if let Some(s) = spec {
q = q.param("spec", s);
}
if let Some(pid) = parent_id {
q = q.param("parent_id", pid);
}
if let Some(p) = priority {
q = q.param("priority", p as i64);
}
if let Some(m) = metadata {
q = q.param("metadata", m);
}
let mut result = self
.graph
.execute(q)
.await
.map_err(|e| neo4j_err("add_task", e))?;
match result
.next()
.await
.map_err(|e| neo4j_err("add_task fetch", e))?
{
Some(row) => row_to_task(&row, "t"),
None => Err(IntentError::OtherError(anyhow::anyhow!(
"add_task: CREATE did not return a node"
))),
}
}
pub async fn update_task(&self, id: i64, update: TaskUpdate<'_>) -> Result<Task> {
let task = self.get_task(id).await?;
if let Some(s) = update.status {
if !["todo", "doing", "done"].contains(&s) {
return Err(IntentError::InvalidInput(format!("Invalid status: {}", s)));
}
}
if let Some(o) = update.owner {
if o.is_empty() {
return Err(IntentError::InvalidInput(
"owner cannot be empty".to_string(),
));
}
}
if let Some(Some(pid)) = update.parent_id {
if pid == id {
return Err(IntentError::CircularDependency {
blocking_task_id: pid,
blocked_task_id: id,
});
}
self.check_task_exists(pid).await?;
self.check_circular_dependency(id, pid).await?;
}
let mut set_parts: Vec<&str> = Vec::new();
if update.name.is_some() {
set_parts.push("t.name = $new_name");
}
if update.spec.is_some() {
set_parts.push("t.spec = $new_spec");
}
if let Some(pid_opt) = update.parent_id {
match pid_opt {
Some(_) => set_parts.push("t.parent_id = $new_parent_id"),
None => set_parts.push("t.parent_id = null"),
}
}
if update.complexity.is_some() {
set_parts.push("t.complexity = $new_complexity");
}
if update.priority.is_some() {
set_parts.push("t.priority = $new_priority");
}
if update.active_form.is_some() {
set_parts.push("t.active_form = $new_active_form");
}
if update.owner.is_some() {
set_parts.push("t.owner = $new_owner");
}
if update.metadata.is_some() {
set_parts.push("t.metadata = $new_metadata");
}
if let Some(s) = update.status {
set_parts.push("t.status = $new_status");
match s {
"todo" if task.first_todo_at.is_none() => set_parts.push("t.first_todo_at = $ts"),
"doing" if task.first_doing_at.is_none() => {
set_parts.push("t.first_doing_at = $ts")
},
"done" if task.first_done_at.is_none() => set_parts.push("t.first_done_at = $ts"),
_ => {},
}
}
if set_parts.is_empty() {
return Ok(task);
}
let mut txn = self
.graph
.start_txn()
.await
.map_err(|e| neo4j_err("update_task start txn", e))?;
let cypher = format!(
"MATCH (t:Task {{project_id: $pid, id: $id}}) SET {}",
set_parts.join(", ")
);
let mut q = query(&cypher)
.param("pid", self.project_id.clone())
.param("id", id);
if let Some(name) = update.name {
q = q.param("new_name", name.to_string());
}
if let Some(spec) = update.spec {
q = q.param("new_spec", spec.to_string());
}
if let Some(Some(pid)) = update.parent_id {
q = q.param("new_parent_id", pid);
}
if let Some(c) = update.complexity {
q = q.param("new_complexity", c as i64);
}
if let Some(p) = update.priority {
q = q.param("new_priority", p as i64);
}
if let Some(af) = update.active_form {
q = q.param("new_active_form", af.to_string());
}
if let Some(o) = update.owner {
q = q.param("new_owner", o.to_string());
}
if let Some(m) = update.metadata {
q = q.param("new_metadata", m.to_string());
}
if let Some(s) = update.status {
q = q.param("new_status", s.to_string());
let needs_ts = match s {
"todo" => task.first_todo_at.is_none(),
"doing" => task.first_doing_at.is_none(),
"done" => task.first_done_at.is_none(),
_ => false,
};
if needs_ts {
q = q.param("ts", Utc::now().to_rfc3339());
}
}
txn.run(q)
.await
.map_err(|e| neo4j_err("update_task set", e))?;
if let Some(pid_opt) = update.parent_id {
txn.run(
query("MATCH (t:Task {project_id: $pid, id: $id})-[r:CHILD_OF]->() DELETE r")
.param("pid", self.project_id.clone())
.param("id", id),
)
.await
.map_err(|e| neo4j_err("update_task remove CHILD_OF", e))?;
if let Some(new_pid) = pid_opt {
txn.run(
query(
"MATCH (t:Task {project_id: $pid, id: $id}), \
(parent:Task {project_id: $pid, id: $parent_id}) \
CREATE (t)-[:CHILD_OF]->(parent)",
)
.param("pid", self.project_id.clone())
.param("id", id)
.param("parent_id", new_pid),
)
.await
.map_err(|e| neo4j_err("update_task create CHILD_OF", e))?;
}
}
txn.commit()
.await
.map_err(|e| neo4j_err("update_task commit", e))?;
self.get_task(id).await
}
pub async fn delete_task(&self, id: i64) -> Result<()> {
self.check_task_exists(id).await?;
if let Some((tid, session_id)) = self.find_focused_in_subtree(id).await? {
return Err(IntentError::ActionNotAllowed(format!(
"Task #{} is focused by session '{}'. Unfocus it first.",
tid, session_id
)));
}
let now = chrono::Utc::now().to_rfc3339();
self.graph
.run(
query("MATCH (t:Task {project_id: $pid, id: $id}) SET t.deleted_at = $now")
.param("pid", self.project_id.clone())
.param("id", id)
.param("now", now),
)
.await
.map_err(|e| neo4j_err("delete_task", e))?;
Ok(())
}
pub async fn delete_task_cascade(&self, id: i64) -> Result<usize> {
self.check_task_exists(id).await?;
if let Some((tid, session_id)) = self.find_focused_in_subtree(id).await? {
return Err(IntentError::ActionNotAllowed(format!(
"Cannot cascade delete: task #{} is focused by session '{}'. Unfocus it first.",
tid, session_id
)));
}
let descendants = self.get_descendants(id).await?;
let count = descendants.len();
let now = chrono::Utc::now().to_rfc3339();
self.graph
.run(
query(
"MATCH (root:Task {project_id: $pid, id: $id}) \
WHERE root.deleted_at IS NULL \
OPTIONAL MATCH (root)-[:CHILD_OF*0..]->(desc:Task {project_id: $pid}) \
WHERE desc.deleted_at IS NULL \
SET root.deleted_at = $now, desc.deleted_at = $now",
)
.param("pid", self.project_id.clone())
.param("id", id)
.param("now", now),
)
.await
.map_err(|e| neo4j_err("delete_task_cascade", e))?;
Ok(count)
}
pub async fn add_dependency(&self, blocking_id: i64, blocked_id: i64) -> Result<()> {
self.check_task_exists(blocking_id).await?;
self.check_task_exists(blocked_id).await?;
let mut result = self
.graph
.execute(
query(
"OPTIONAL MATCH path = (a:Task {project_id: $pid, id: $blocking_id})-[:BLOCKED_BY*1..]->(b:Task {project_id: $pid, id: $blocked_id}) \
RETURN path IS NOT NULL AS is_cycle",
)
.param("pid", self.project_id.clone())
.param("blocking_id", blocking_id)
.param("blocked_id", blocked_id),
)
.await
.map_err(|e| neo4j_err("add_dependency cycle check", e))?;
if let Some(row) = result
.next()
.await
.map_err(|e| neo4j_err("add_dependency cycle check fetch", e))?
{
let is_cycle: bool = row.get("is_cycle").unwrap_or(false);
if is_cycle {
return Err(IntentError::CircularDependency {
blocking_task_id: blocking_id,
blocked_task_id: blocked_id,
});
}
}
self.graph
.run(
query(
"MATCH (blocked:Task {project_id: $pid, id: $blocked_id}), \
(blocking:Task {project_id: $pid, id: $blocking_id}) \
MERGE (blocked)-[:BLOCKED_BY]->(blocking)",
)
.param("pid", self.project_id.clone())
.param("blocked_id", blocked_id)
.param("blocking_id", blocking_id),
)
.await
.map_err(|e| neo4j_err("add_dependency", e))?;
Ok(())
}
pub async fn remove_dependency(&self, blocking_id: i64, blocked_id: i64) -> Result<()> {
self.graph
.run(
query(
"MATCH (blocked:Task {project_id: $pid, id: $blocked_id})-[r:BLOCKED_BY]->(blocking:Task {project_id: $pid, id: $blocking_id}) \
DELETE r",
)
.param("pid", self.project_id.clone())
.param("blocked_id", blocked_id)
.param("blocking_id", blocking_id),
)
.await
.map_err(|e| neo4j_err("remove_dependency", e))?;
Ok(())
}
pub async fn find_tasks(
&self,
status: Option<String>,
parent_id: Option<Option<i64>>,
sort_by: Option<TaskSortBy>,
limit: Option<i64>,
offset: Option<i64>,
) -> Result<PaginatedTasks> {
let sort_by = sort_by.unwrap_or_default();
let limit = limit.unwrap_or(100);
let offset = offset.unwrap_or(0);
let mut where_parts = vec![
"t.project_id = $pid".to_string(),
"t.deleted_at IS NULL".to_string(),
];
let mut has_status_filter = false;
let mut has_parent_filter = false;
if status.is_some() {
where_parts.push("t.status = $filter_status".to_string());
has_status_filter = true;
}
if let Some(pid_opt) = parent_id {
match pid_opt {
Some(_) => {
has_parent_filter = true;
},
None => {
where_parts.push("NOT (t)-[:CHILD_OF]->()".to_string());
},
}
}
let where_clause = where_parts.join(" AND ");
let order_clause = match sort_by {
TaskSortBy::Id => "ORDER BY t.id ASC".to_string(),
TaskSortBy::Priority => {
"ORDER BY COALESCE(t.priority, 999) ASC, COALESCE(t.complexity, 5) ASC, t.id ASC"
.to_string()
},
TaskSortBy::Time => "ORDER BY \
CASE t.status \
WHEN 'doing' THEN t.first_doing_at \
WHEN 'todo' THEN t.first_todo_at \
WHEN 'done' THEN t.first_done_at \
END ASC, t.id ASC"
.to_string(),
TaskSortBy::FocusAware => "ORDER BY \
CASE t.status \
WHEN 'doing' THEN 0 \
WHEN 'todo' THEN 1 \
WHEN 'done' THEN 2 \
ELSE 3 \
END ASC, \
COALESCE(t.priority, 999) ASC, t.id ASC"
.to_string(),
};
let count_cypher = if has_parent_filter {
format!(
"MATCH (t:Task)-[:CHILD_OF]->(parent:Task {{project_id: $pid, id: $parent_id}}) \
WHERE {} RETURN count(t) AS cnt",
where_clause
)
} else {
format!(
"MATCH (t:Task) WHERE {} RETURN count(t) AS cnt",
where_clause
)
};
let status_value = status.as_deref().map(str::to_string);
let mut count_q = query(&count_cypher).param("pid", self.project_id.clone());
if has_status_filter {
count_q = count_q.param("filter_status", status_value.clone().unwrap());
}
if has_parent_filter {
count_q = count_q.param("parent_id", parent_id.unwrap().unwrap());
}
let mut count_result = self
.graph
.execute(count_q)
.await
.map_err(|e| neo4j_err("find_tasks count", e))?;
let total_count: i64 = match count_result
.next()
.await
.map_err(|e| neo4j_err("find_tasks count fetch", e))?
{
Some(row) => row
.get("cnt")
.map_err(|e| neo4j_err("find_tasks count value", e))?,
None => 0,
};
let main_cypher = if has_parent_filter {
format!(
"MATCH (t:Task)-[:CHILD_OF]->(parent:Task {{project_id: $pid, id: $parent_id}}) \
WHERE {} {} SKIP $offset LIMIT $limit RETURN t",
where_clause, order_clause
)
} else {
format!(
"MATCH (t:Task) WHERE {} {} SKIP $offset LIMIT $limit RETURN t",
where_clause, order_clause
)
};
let mut main_q = query(&main_cypher)
.param("pid", self.project_id.clone())
.param("offset", offset)
.param("limit", limit);
if has_status_filter {
main_q = main_q.param("filter_status", status_value.unwrap());
}
if has_parent_filter {
main_q = main_q.param("parent_id", parent_id.unwrap().unwrap());
}
let mut result = self
.graph
.execute(main_q)
.await
.map_err(|e| neo4j_err("find_tasks query", e))?;
let mut tasks = Vec::new();
while let Some(row) = result
.next()
.await
.map_err(|e| neo4j_err("find_tasks iterate", e))?
{
tasks.push(row_to_task(&row, "t")?);
}
let has_more = offset + (tasks.len() as i64) < total_count;
Ok(PaginatedTasks {
tasks,
total_count,
has_more,
limit,
offset,
})
}
pub async fn start_task(&self, id: i64, with_events: bool) -> Result<TaskWithEvents> {
self.check_task_exists(id).await?;
let blocking_ids = self.get_blocking_task_ids(id).await?;
if !blocking_ids.is_empty() {
return Err(IntentError::TaskBlocked {
task_id: id,
blocking_task_ids: blocking_ids,
});
}
let now = Utc::now().to_rfc3339();
let session_id = crate::workspace::resolve_session_id(None);
let mut txn = self
.graph
.start_txn()
.await
.map_err(|e| neo4j_err("start_task start txn", e))?;
txn.run(
query(
"MATCH (t:Task {project_id: $pid, id: $id}) \
SET t.status = 'doing', \
t.first_doing_at = COALESCE(t.first_doing_at, $now)",
)
.param("pid", self.project_id.clone())
.param("id", id)
.param("now", now),
)
.await
.map_err(|e| neo4j_err("start_task update", e))?;
txn.run(
query(
"MERGE (s:Session {project_id: $pid, session_id: $sid}) \
ON CREATE SET s.created_at = datetime(), s.last_active_at = datetime() \
ON MATCH SET s.last_active_at = datetime() \
SET s.current_task_id = $tid",
)
.param("pid", self.project_id.clone())
.param("sid", session_id)
.param("tid", id),
)
.await
.map_err(|e| neo4j_err("start_task set session", e))?;
txn.commit()
.await
.map_err(|e| neo4j_err("start_task commit", e))?;
if with_events {
self.get_task_with_events(id).await
} else {
let task = self.get_task(id).await?;
Ok(TaskWithEvents {
task,
events_summary: None,
})
}
}
pub async fn done_task_by_id(&self, id: i64, is_ai_caller: bool) -> Result<DoneTaskResponse> {
let _ = is_ai_caller; let task = self.get_task(id).await?;
let incomplete = self.count_incomplete_children(id).await?;
if incomplete > 0 {
return Err(IntentError::UncompletedChildren);
}
let now = Utc::now().to_rfc3339();
let session_id = crate::workspace::resolve_session_id(None);
let mut txn = self
.graph
.start_txn()
.await
.map_err(|e| neo4j_err("done_task start txn", e))?;
txn.run(
query(
"MATCH (t:Task {project_id: $pid, id: $id}) \
SET t.status = 'done', \
t.first_done_at = COALESCE(t.first_done_at, $now)",
)
.param("pid", self.project_id.clone())
.param("id", id)
.param("now", now),
)
.await
.map_err(|e| neo4j_err("done_task set done", e))?;
txn.run(
query(
"MATCH (s:Session {project_id: $pid, session_id: $sid}) \
WHERE s.current_task_id = $tid \
SET s.current_task_id = null, s.last_active_at = datetime()",
)
.param("pid", self.project_id.clone())
.param("sid", session_id.clone())
.param("tid", id),
)
.await
.map_err(|e| neo4j_err("done_task clear focus", e))?;
txn.commit()
.await
.map_err(|e| neo4j_err("done_task commit", e))?;
let mut focus_result = self
.graph
.execute(
query(
"OPTIONAL MATCH (s:Session {project_id: $pid, session_id: $sid}) \
RETURN s.current_task_id AS current_task_id",
)
.param("pid", self.project_id.clone())
.param("sid", session_id),
)
.await
.map_err(|e| neo4j_err("done_task read focus", e))?;
let actual_current_task_id: Option<i64> = focus_result
.next()
.await
.map_err(|e| neo4j_err("done_task read focus fetch", e))?
.and_then(|row| row.get("current_task_id").ok());
let next_step_suggestion = self
.build_next_step_suggestion(id, &task.name, task.parent_id)
.await?;
let completed_task = self.get_task(id).await?;
Ok(DoneTaskResponse {
completed_task,
workspace_status: WorkspaceStatus {
current_task_id: actual_current_task_id,
},
next_step_suggestion,
})
}
pub async fn done_task(&self, is_ai_caller: bool) -> Result<DoneTaskResponse> {
let session_id = crate::workspace::resolve_session_id(None);
let mut result = self
.graph
.execute(
query(
"OPTIONAL MATCH (s:Session {project_id: $pid, session_id: $sid}) \
RETURN s.current_task_id AS current_task_id",
)
.param("pid", self.project_id.clone())
.param("sid", session_id),
)
.await
.map_err(|e| neo4j_err("done_task get focus", e))?;
let current_task_id: Option<i64> = result
.next()
.await
.map_err(|e| neo4j_err("done_task get focus fetch", e))?
.and_then(|row| row.get("current_task_id").ok());
let id = current_task_id.ok_or(IntentError::InvalidInput(
"No current task is set. Use 'ie-neo4j task start <ID>' to set a task first."
.to_string(),
))?;
self.done_task_by_id(id, is_ai_caller).await
}
pub async fn pick_next(&self) -> Result<PickNextResponse> {
let session_id = crate::workspace::resolve_session_id(None);
let mut focus_result = self
.graph
.execute(
query(
"OPTIONAL MATCH (s:Session {project_id: $pid, session_id: $sid}) \
RETURN s.current_task_id AS current_task_id",
)
.param("pid", self.project_id.clone())
.param("sid", session_id),
)
.await
.map_err(|e| neo4j_err("pick_next get focus", e))?;
let current_task_id: Option<i64> = focus_result
.next()
.await
.map_err(|e| neo4j_err("pick_next get focus fetch", e))?
.and_then(|row| row.get("current_task_id").ok());
if let Some(current_id) = current_task_id {
if let Some(task) = self.find_child_by_status(current_id, "doing").await? {
return Ok(PickNextResponse::focused_subtask(task));
}
if let Some(task) = self.find_child_by_status(current_id, "todo").await? {
return Ok(PickNextResponse::focused_subtask(task));
}
}
if let Some(task) = self
.find_top_level_by_status("doing", current_task_id)
.await?
{
return Ok(PickNextResponse::top_level_task(task));
}
if let Some(task) = self.find_top_level_by_status("todo", None).await? {
return Ok(PickNextResponse::top_level_task(task));
}
let mut count_result = self
.graph
.execute(
query("MATCH (t:Task {project_id: $pid}) RETURN count(t) AS cnt")
.param("pid", self.project_id.clone()),
)
.await
.map_err(|e| neo4j_err("pick_next count", e))?;
let total: i64 = count_result
.next()
.await
.map_err(|e| neo4j_err("pick_next count fetch", e))?
.and_then(|row| row.get::<i64>("cnt").ok())
.unwrap_or(0);
if total == 0 {
return Ok(PickNextResponse::no_tasks_in_project());
}
let mut incomplete_result = self
.graph
.execute(
query(
"MATCH (t:Task {project_id: $pid}) WHERE t.status <> 'done' \
RETURN count(t) AS cnt",
)
.param("pid", self.project_id.clone()),
)
.await
.map_err(|e| neo4j_err("pick_next incomplete count", e))?;
let incomplete: i64 = incomplete_result
.next()
.await
.map_err(|e| neo4j_err("pick_next incomplete fetch", e))?
.and_then(|row| row.get::<i64>("cnt").ok())
.unwrap_or(0);
if incomplete == 0 {
return Ok(PickNextResponse::all_tasks_completed());
}
Ok(PickNextResponse::no_available_todos())
}
pub async fn get_blocking_tasks(&self, task_id: i64) -> Result<Vec<Task>> {
let mut result = self
.graph
.execute(
query(
"MATCH (t:Task {project_id: $pid, id: $id})-[:BLOCKED_BY]->(b:Task {project_id: $pid}) \
RETURN b ORDER BY b.id ASC",
)
.param("pid", self.project_id.clone())
.param("id", task_id),
)
.await
.map_err(|e| neo4j_err("get_blocking_tasks", e))?;
let mut tasks = Vec::new();
while let Some(row) = result
.next()
.await
.map_err(|e| neo4j_err("get_blocking_tasks iterate", e))?
{
tasks.push(row_to_task(&row, "b")?);
}
Ok(tasks)
}
pub async fn get_blocked_by_tasks(&self, task_id: i64) -> Result<Vec<Task>> {
let mut result = self
.graph
.execute(
query(
"MATCH (b:Task {project_id: $pid})-[:BLOCKED_BY]->(t:Task {project_id: $pid, id: $id}) \
RETURN b ORDER BY b.id ASC",
)
.param("pid", self.project_id.clone())
.param("id", task_id),
)
.await
.map_err(|e| neo4j_err("get_blocked_by_tasks", e))?;
let mut tasks = Vec::new();
while let Some(row) = result
.next()
.await
.map_err(|e| neo4j_err("get_blocked_by_tasks iterate", e))?
{
tasks.push(row_to_task(&row, "b")?);
}
Ok(tasks)
}
pub async fn get_task_context(&self, id: i64) -> Result<crate::db::models::TaskContext> {
let task = self.get_task(id).await?;
let (ancestors, siblings, children, blocking_tasks, blocked_by_tasks) = tokio::try_join!(
self.get_task_ancestry(id),
self.get_siblings(id, task.parent_id),
self.get_children(id),
self.get_blocking_tasks(id),
self.get_blocked_by_tasks(id),
)?;
Ok(crate::db::models::TaskContext {
task,
ancestors,
siblings,
children,
dependencies: crate::db::models::TaskDependencies {
blocking_tasks,
blocked_by_tasks,
},
})
}
pub async fn get_task_context_with_events(
&self,
id: i64,
) -> Result<(
crate::db::models::TaskContext,
crate::db::models::EventsSummary,
)> {
let task = self.get_task(id).await?;
let event_mgr = super::Neo4jEventManager::new(self.graph.clone(), self.project_id.clone());
let (ancestors, siblings, children, blocking_tasks, blocked_by_tasks, events_summary) = tokio::try_join!(
self.get_task_ancestry(id),
self.get_siblings(id, task.parent_id),
self.get_children(id),
self.get_blocking_tasks(id),
self.get_blocked_by_tasks(id),
event_mgr.get_events_summary(id),
)?;
let context = crate::db::models::TaskContext {
task,
ancestors,
siblings,
children,
dependencies: crate::db::models::TaskDependencies {
blocking_tasks,
blocked_by_tasks,
},
};
Ok((context, events_summary))
}
pub async fn get_task_with_events(&self, id: i64) -> Result<TaskWithEvents> {
let task = self.get_task(id).await?;
let event_mgr = super::Neo4jEventManager::new(self.graph.clone(), self.project_id.clone());
let events_summary = event_mgr.get_events_summary(id).await?;
Ok(TaskWithEvents {
task,
events_summary: Some(events_summary),
})
}
pub(crate) async fn find_focused_in_subtree(
&self,
task_id: i64,
) -> Result<Option<(i64, String)>> {
let mut result = self
.graph
.execute(
query(
"MATCH (desc:Task {project_id: $pid})-[:CHILD_OF*0..]->(root:Task {project_id: $pid, id: $id}) \
WITH collect(desc.id) AS subtree_ids \
MATCH (s:Session {project_id: $pid}) \
WHERE s.current_task_id IN subtree_ids \
RETURN s.current_task_id AS tid, s.session_id AS sid \
LIMIT 1",
)
.param("pid", self.project_id.clone())
.param("id", task_id),
)
.await
.map_err(|e| neo4j_err("find_focused_in_subtree", e))?;
match result
.next()
.await
.map_err(|e| neo4j_err("find_focused_in_subtree fetch", e))?
{
Some(row) => {
let tid: i64 = row
.get("tid")
.map_err(|e| neo4j_err("find_focused_in_subtree tid", e))?;
let sid: String = row
.get("sid")
.map_err(|e| neo4j_err("find_focused_in_subtree sid", e))?;
Ok(Some((tid, sid)))
},
None => Ok(None),
}
}
async fn get_blocking_task_ids(&self, task_id: i64) -> Result<Vec<i64>> {
let mut result = self
.graph
.execute(
query(
"MATCH (t:Task {project_id: $pid, id: $id})-[:BLOCKED_BY]->(blocking:Task {project_id: $pid}) \
WHERE blocking.status IN ['todo', 'doing'] \
RETURN blocking.id AS blocking_id",
)
.param("pid", self.project_id.clone())
.param("id", task_id),
)
.await
.map_err(|e| neo4j_err("get_blocking_task_ids", e))?;
let mut ids = Vec::new();
while let Some(row) = result
.next()
.await
.map_err(|e| neo4j_err("get_blocking_task_ids iterate", e))?
{
if let Ok(bid) = row.get::<i64>("blocking_id") {
ids.push(bid);
}
}
Ok(ids)
}
async fn check_task_exists(&self, id: i64) -> Result<()> {
let mut result = self
.graph
.execute(
query(
"MATCH (t:Task {project_id: $pid, id: $id}) \
WHERE t.deleted_at IS NULL RETURN t.id AS id",
)
.param("pid", self.project_id.clone())
.param("id", id),
)
.await
.map_err(|e| neo4j_err("check_task_exists", e))?;
match result
.next()
.await
.map_err(|e| neo4j_err("check_task_exists fetch", e))?
{
Some(_) => Ok(()),
None => Err(IntentError::TaskNotFound(id)),
}
}
async fn check_circular_dependency(&self, task_id: i64, new_parent_id: i64) -> Result<()> {
let mut result = self
.graph
.execute(
query(
"OPTIONAL MATCH path = (p:Task {project_id: $pid, id: $new_parent})-[:CHILD_OF*0..]->(t:Task {project_id: $pid, id: $task_id}) \
RETURN path IS NOT NULL AS is_cycle",
)
.param("pid", self.project_id.clone())
.param("new_parent", new_parent_id)
.param("task_id", task_id),
)
.await
.map_err(|e| neo4j_err("check_circular_dependency", e))?;
if let Some(row) = result
.next()
.await
.map_err(|e| neo4j_err("check_circular_dependency fetch", e))?
{
let is_cycle: bool = row.get("is_cycle").unwrap_or(false);
if is_cycle {
return Err(IntentError::CircularDependency {
blocking_task_id: new_parent_id,
blocked_task_id: task_id,
});
}
}
Ok(())
}
async fn count_incomplete_children(&self, task_id: i64) -> Result<i64> {
let mut result = self
.graph
.execute(
query(
"MATCH (child:Task {project_id: $pid})-[:CHILD_OF]->(t:Task {project_id: $pid, id: $id}) \
WHERE child.status <> 'done' \
RETURN count(child) AS cnt",
)
.param("pid", self.project_id.clone())
.param("id", task_id),
)
.await
.map_err(|e| neo4j_err("count_incomplete_children", e))?;
Ok(result
.next()
.await
.map_err(|e| neo4j_err("count_incomplete_children fetch", e))?
.and_then(|row| row.get::<i64>("cnt").ok())
.unwrap_or(0))
}
async fn count_children(&self, task_id: i64) -> Result<i64> {
let mut result = self
.graph
.execute(
query(
"MATCH (child:Task {project_id: $pid})-[:CHILD_OF]->(t:Task {project_id: $pid, id: $id}) \
RETURN count(child) AS cnt",
)
.param("pid", self.project_id.clone())
.param("id", task_id),
)
.await
.map_err(|e| neo4j_err("count_children", e))?;
Ok(result
.next()
.await
.map_err(|e| neo4j_err("count_children fetch", e))?
.and_then(|row| row.get::<i64>("cnt").ok())
.unwrap_or(0))
}
async fn find_child_by_status(&self, parent_id: i64, status: &str) -> Result<Option<Task>> {
let mut result = self
.graph
.execute(
query(
"MATCH (child:Task {project_id: $pid})-[:CHILD_OF]->(parent:Task {project_id: $pid, id: $parent_id}) \
WHERE child.status = $status \
RETURN child \
ORDER BY COALESCE(child.priority, 999) ASC, child.id ASC \
LIMIT 1",
)
.param("pid", self.project_id.clone())
.param("parent_id", parent_id)
.param("status", status.to_string()),
)
.await
.map_err(|e| neo4j_err("find_child_by_status", e))?;
match result
.next()
.await
.map_err(|e| neo4j_err("find_child_by_status fetch", e))?
{
Some(row) => Ok(Some(row_to_task(&row, "child")?)),
None => Ok(None),
}
}
async fn find_top_level_by_status(
&self,
status: &str,
exclude_id: Option<i64>,
) -> Result<Option<Task>> {
let cypher = if exclude_id.is_some() {
"MATCH (t:Task {project_id: $pid}) \
WHERE NOT (t)-[:CHILD_OF]->() AND t.status = $status AND t.id <> $exclude_id \
RETURN t \
ORDER BY COALESCE(t.priority, 999) ASC, t.id ASC \
LIMIT 1"
} else {
"MATCH (t:Task {project_id: $pid}) \
WHERE NOT (t)-[:CHILD_OF]->() AND t.status = $status \
RETURN t \
ORDER BY COALESCE(t.priority, 999) ASC, t.id ASC \
LIMIT 1"
};
let mut q = query(cypher)
.param("pid", self.project_id.clone())
.param("status", status.to_string());
if let Some(eid) = exclude_id {
q = q.param("exclude_id", eid);
}
let mut result = self
.graph
.execute(q)
.await
.map_err(|e| neo4j_err("find_top_level_by_status", e))?;
match result
.next()
.await
.map_err(|e| neo4j_err("find_top_level_by_status fetch", e))?
{
Some(row) => Ok(Some(row_to_task(&row, "t")?)),
None => Ok(None),
}
}
async fn build_next_step_suggestion(
&self,
id: i64,
task_name: &str,
parent_id: Option<i64>,
) -> Result<NextStepSuggestion> {
if let Some(parent_task_id) = parent_id {
let mut result = self
.graph
.execute(
query(
"MATCH (sibling:Task {project_id: $pid})-[:CHILD_OF]->(parent:Task {project_id: $pid, id: $parent_id}) \
WHERE sibling.status <> 'done' AND sibling.id <> $id \
RETURN count(sibling) AS cnt",
)
.param("pid", self.project_id.clone())
.param("parent_id", parent_task_id)
.param("id", id),
)
.await
.map_err(|e| neo4j_err("next_step remaining siblings", e))?;
let remaining_siblings: i64 = result
.next()
.await
.map_err(|e| neo4j_err("next_step siblings fetch", e))?
.and_then(|row| row.get::<i64>("cnt").ok())
.unwrap_or(0);
let parent = self.get_task(parent_task_id).await?;
let parent_name = parent.name;
if remaining_siblings == 0 {
Ok(NextStepSuggestion::ParentIsReady {
message: format!(
"All sub-tasks of parent #{} '{}' are now complete. The parent task is ready for your attention.",
parent_task_id, parent_name
),
parent_task_id,
parent_task_name: parent_name,
})
} else {
Ok(NextStepSuggestion::SiblingTasksRemain {
message: format!(
"Task #{} completed. Parent task #{} '{}' has other sub-tasks remaining.",
id, parent_task_id, parent_name
),
parent_task_id,
parent_task_name: parent_name,
remaining_siblings_count: remaining_siblings,
})
}
} else {
let child_count = self.count_children(id).await?;
if child_count > 0 {
Ok(NextStepSuggestion::TopLevelTaskCompleted {
message: format!(
"Top-level task #{} '{}' has been completed. Well done!",
id, task_name
),
completed_task_id: id,
completed_task_name: task_name.to_string(),
})
} else {
let mut result = self
.graph
.execute(
query(
"MATCH (t:Task {project_id: $pid}) \
WHERE t.status <> 'done' AND t.id <> $id \
RETURN count(t) AS cnt",
)
.param("pid", self.project_id.clone())
.param("id", id),
)
.await
.map_err(|e| neo4j_err("next_step remaining tasks", e))?;
let remaining_tasks: i64 = result
.next()
.await
.map_err(|e| neo4j_err("next_step remaining fetch", e))?
.and_then(|row| row.get::<i64>("cnt").ok())
.unwrap_or(0);
if remaining_tasks == 0 {
Ok(NextStepSuggestion::WorkspaceIsClear {
message: format!(
"Project complete! Task #{} was the last remaining task. There are no more 'todo' or 'doing' tasks.",
id
),
completed_task_id: id,
})
} else {
Ok(NextStepSuggestion::NoParentContext {
message: format!("Task #{} '{}' has been completed.", id, task_name),
completed_task_id: id,
completed_task_name: task_name.to_string(),
})
}
}
}
}
}
pub(crate) fn row_to_task(row: &neo4rs::Row, column: &str) -> Result<Task> {
let node: neo4rs::Node = row
.get(column)
.map_err(|e| neo4j_err(&format!("get column '{column}'"), e))?;
node_to_task(&node)
}
pub(crate) fn node_to_task(node: &neo4rs::Node) -> Result<Task> {
let id: i64 = node.get("id").map_err(|e| neo4j_err("task.id", e))?;
let name: String = node.get("name").map_err(|e| neo4j_err("task.name", e))?;
let parent_id: Option<i64> = node.get("parent_id").ok();
let spec: Option<String> = node.get("spec").ok();
let complexity: Option<i32> = node.get("complexity").ok();
let priority: Option<i32> = node.get("priority").ok();
let active_form: Option<String> = node.get("active_form").ok();
let metadata: Option<String> = node.get("metadata").ok();
let status: String = node.get("status").unwrap_or_else(|_| "todo".into());
let owner: String = node.get("owner").unwrap_or_else(|_| "human".into());
let first_todo_at = parse_datetime_prop(node, "first_todo_at");
let first_doing_at = parse_datetime_prop(node, "first_doing_at");
let first_done_at = parse_datetime_prop(node, "first_done_at");
Ok(Task {
id,
parent_id,
name,
spec,
status,
complexity,
priority,
first_todo_at,
first_doing_at,
first_done_at,
active_form,
owner,
metadata,
})
}
fn parse_datetime_prop(node: &neo4rs::Node, key: &str) -> Option<DateTime<Utc>> {
let s: String = node.get(key).ok()?;
parse_datetime_str(&s)
}
fn parse_datetime_str(s: &str) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(s)
.map(|dt| dt.with_timezone(&Utc))
.ok()
.or_else(|| s.parse::<DateTime<Utc>>().ok())
}
pub(crate) fn neo4j_err(context: &str, e: impl std::fmt::Display) -> IntentError {
IntentError::OtherError(anyhow::anyhow!("Neo4j {}: {}", context, e))
}
impl crate::backend::TaskBackend for Neo4jTaskManager {
fn get_task(&self, id: i64) -> impl std::future::Future<Output = Result<Task>> + Send {
self.get_task(id)
}
fn get_task_with_events(
&self,
id: i64,
) -> impl std::future::Future<Output = Result<TaskWithEvents>> + Send {
self.get_task_with_events(id)
}
fn get_task_ancestry(
&self,
task_id: i64,
) -> impl std::future::Future<Output = Result<Vec<Task>>> + Send {
self.get_task_ancestry(task_id)
}
fn get_task_context(
&self,
id: i64,
) -> impl std::future::Future<Output = Result<crate::db::models::TaskContext>> + Send {
self.get_task_context(id)
}
fn get_siblings(
&self,
id: i64,
parent_id: Option<i64>,
) -> impl std::future::Future<Output = Result<Vec<Task>>> + Send {
self.get_siblings(id, parent_id)
}
fn get_children(&self, id: i64) -> impl std::future::Future<Output = Result<Vec<Task>>> + Send {
self.get_children(id)
}
fn get_blocking_tasks(
&self,
id: i64,
) -> impl std::future::Future<Output = Result<Vec<Task>>> + Send {
self.get_blocking_tasks(id)
}
fn get_blocked_by_tasks(
&self,
id: i64,
) -> impl std::future::Future<Output = Result<Vec<Task>>> + Send {
self.get_blocked_by_tasks(id)
}
fn get_descendants(
&self,
task_id: i64,
) -> impl std::future::Future<Output = Result<Vec<Task>>> + Send {
self.get_descendants(task_id)
}
fn get_status(
&self,
task_id: i64,
with_events: bool,
) -> impl std::future::Future<Output = Result<crate::db::models::StatusResponse>> + Send {
self.get_status(task_id, with_events)
}
fn get_root_tasks(&self) -> impl std::future::Future<Output = Result<Vec<Task>>> + Send {
self.get_root_tasks()
}
fn find_tasks(
&self,
status: Option<String>,
parent_id: Option<Option<i64>>,
sort_by: Option<TaskSortBy>,
limit: Option<i64>,
offset: Option<i64>,
) -> impl std::future::Future<Output = Result<PaginatedTasks>> + Send {
self.find_tasks(status, parent_id, sort_by, limit, offset)
}
fn add_task(
&self,
name: String,
spec: Option<String>,
parent_id: Option<i64>,
owner: Option<String>,
priority: Option<i32>,
metadata: Option<String>,
) -> impl std::future::Future<Output = Result<Task>> + Send {
self.add_task(name, spec, parent_id, owner, priority, metadata)
}
fn update_task(
&self,
id: i64,
update: crate::tasks::TaskUpdate<'_>,
) -> impl std::future::Future<Output = Result<Task>> + Send {
self.update_task(id, update)
}
fn delete_task(&self, id: i64) -> impl std::future::Future<Output = Result<()>> + Send {
self.delete_task(id)
}
fn delete_task_cascade(
&self,
id: i64,
) -> impl std::future::Future<Output = Result<usize>> + Send {
self.delete_task_cascade(id)
}
fn add_dependency(
&self,
blocking_id: i64,
blocked_id: i64,
) -> impl std::future::Future<Output = Result<()>> + Send {
self.add_dependency(blocking_id, blocked_id)
}
fn remove_dependency(
&self,
blocking_id: i64,
blocked_id: i64,
) -> impl std::future::Future<Output = Result<()>> + Send {
self.remove_dependency(blocking_id, blocked_id)
}
fn start_task(
&self,
id: i64,
with_events: bool,
) -> impl std::future::Future<Output = Result<TaskWithEvents>> + Send {
self.start_task(id, with_events)
}
fn done_task(
&self,
is_ai_caller: bool,
) -> impl std::future::Future<Output = Result<DoneTaskResponse>> + Send {
self.done_task(is_ai_caller)
}
fn done_task_by_id(
&self,
id: i64,
is_ai_caller: bool,
) -> impl std::future::Future<Output = Result<DoneTaskResponse>> + Send {
self.done_task_by_id(id, is_ai_caller)
}
fn pick_next(&self) -> impl std::future::Future<Output = Result<PickNextResponse>> + Send {
self.pick_next()
}
}
impl crate::backend::SearchBackend for Neo4jTaskManager {
fn search(
&self,
query: String,
include_tasks: bool,
include_events: bool,
limit: Option<i64>,
offset: Option<i64>,
) -> impl std::future::Future<Output = Result<crate::db::models::PaginatedSearchResults>> + Send
{
use crate::neo4j::search_manager::Neo4jSearchManager;
let mgr = Neo4jSearchManager::new(self.graph.clone(), self.project_id.clone());
async move {
mgr.search(&query, include_tasks, include_events, limit, offset)
.await
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_datetime_str_rfc3339() {
use chrono::Datelike;
let dt = parse_datetime_str("2026-02-14T10:30:00Z");
assert!(dt.is_some());
let dt = dt.unwrap();
assert_eq!(dt.year(), 2026);
assert_eq!(dt.month(), 2);
assert_eq!(dt.day(), 14);
}
#[test]
fn test_parse_datetime_str_with_offset() {
use chrono::Timelike;
let dt = parse_datetime_str("2026-02-14T10:30:00+08:00");
assert!(dt.is_some());
let dt = dt.unwrap();
assert_eq!(dt.hour(), 2); }
#[test]
fn test_parse_datetime_str_invalid() {
assert!(parse_datetime_str("not-a-date").is_none());
assert!(parse_datetime_str("").is_none());
assert!(parse_datetime_str("2026-13-45").is_none());
}
#[test]
fn test_parse_datetime_str_iso8601_no_offset() {
let dt = parse_datetime_str("2026-02-14T10:30:00Z");
assert!(dt.is_some());
}
#[test]
fn test_neo4j_err_includes_context() {
let err = neo4j_err("get_task query", "connection refused");
let msg = err.to_string();
assert!(msg.contains("Neo4j get_task query"));
assert!(msg.contains("connection refused"));
}
}