use crate::db::models::{
DoneTaskResponse, Event, EventsSummary, NextStepSuggestion, PaginatedTasks, ParentTaskInfo,
PickNextResponse, SpawnSubtaskResponse, SubtaskInfo, Task, TaskSortBy, TaskWithEvents,
WorkspaceStats, WorkspaceStatus,
};
use crate::error::{IntentError, Result};
use chrono::Utc;
use sqlx::SqlitePool;
use std::sync::Arc;
pub use crate::db::models::TaskContext;
#[derive(Debug, Clone)]
pub struct DeleteTaskResult {
pub found: bool,
pub descendant_count: i64,
}
#[derive(Debug, Default)]
pub struct TaskUpdate<'a> {
pub name: Option<&'a str>,
pub spec: Option<&'a str>,
pub parent_id: Option<Option<i64>>,
pub status: Option<&'a str>,
pub complexity: Option<i32>,
pub priority: Option<i32>,
pub active_form: Option<&'a str>,
pub owner: Option<&'a str>,
pub metadata: Option<&'a str>,
}
pub struct TaskManager<'a> {
pool: &'a SqlitePool,
notifier: crate::notifications::NotificationSender,
cli_notifier: Option<crate::dashboard::cli_notifier::CliNotifier>,
project_path: Option<String>,
}
impl<'a> TaskManager<'a> {
pub fn new(pool: &'a SqlitePool) -> Self {
Self {
pool,
notifier: crate::notifications::NotificationSender::new(None),
cli_notifier: Some(crate::dashboard::cli_notifier::CliNotifier::new()),
project_path: None,
}
}
pub fn with_project_path(pool: &'a SqlitePool, project_path: String) -> Self {
Self {
pool,
notifier: crate::notifications::NotificationSender::new(None),
cli_notifier: Some(crate::dashboard::cli_notifier::CliNotifier::new()),
project_path: Some(project_path),
}
}
pub fn with_websocket(
pool: &'a SqlitePool,
ws_state: Arc<crate::dashboard::websocket::WebSocketState>,
project_path: String,
) -> Self {
Self {
pool,
notifier: crate::notifications::NotificationSender::new(Some(ws_state)),
cli_notifier: None, project_path: Some(project_path),
}
}
async fn notify_task_created(&self, task: &Task) {
use crate::dashboard::websocket::DatabaseOperationPayload;
if let Some(project_path) = &self.project_path {
let task_json = match serde_json::to_value(task) {
Ok(json) => json,
Err(e) => {
tracing::warn!(error = %e, "Failed to serialize task for notification");
return;
},
};
let payload =
DatabaseOperationPayload::task_created(task.id, task_json, project_path.clone());
self.notifier.send(payload).await;
}
if let Some(cli_notifier) = &self.cli_notifier {
cli_notifier
.notify_task_changed(Some(task.id), "created", self.project_path.clone())
.await;
}
}
async fn notify_task_updated(&self, task: &Task) {
use crate::dashboard::websocket::DatabaseOperationPayload;
if let Some(project_path) = &self.project_path {
let task_json = match serde_json::to_value(task) {
Ok(json) => json,
Err(e) => {
tracing::warn!(error = %e, "Failed to serialize task for notification");
return;
},
};
let payload =
DatabaseOperationPayload::task_updated(task.id, task_json, project_path.clone());
self.notifier.send(payload).await;
}
if let Some(cli_notifier) = &self.cli_notifier {
cli_notifier
.notify_task_changed(Some(task.id), "updated", self.project_path.clone())
.await;
}
}
async fn notify_task_deleted(&self, task_id: i64) {
use crate::dashboard::websocket::DatabaseOperationPayload;
if let Some(project_path) = &self.project_path {
let payload = DatabaseOperationPayload::task_deleted(task_id, project_path.clone());
self.notifier.send(payload).await;
}
if let Some(cli_notifier) = &self.cli_notifier {
cli_notifier
.notify_task_changed(Some(task_id), "deleted", self.project_path.clone())
.await;
}
}
#[tracing::instrument(skip(self), fields(task_name = %name))]
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 now = Utc::now();
let owner = owner.as_deref().unwrap_or("human").to_string();
let result = sqlx::query(
r#"
INSERT INTO tasks (name, spec, parent_id, status, first_todo_at, owner, priority, metadata)
VALUES (?, ?, ?, 'todo', ?, ?, ?, ?)
"#,
)
.bind(name)
.bind(spec)
.bind(parent_id)
.bind(now)
.bind(owner)
.bind(priority)
.bind(metadata)
.execute(self.pool)
.await?;
let id = result.last_insert_rowid();
let task = self.get_task(id).await?;
self.notify_task_created(&task).await;
Ok(task)
}
#[allow(clippy::too_many_arguments)]
pub async fn create_task_in_tx(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
name: &str,
spec: Option<&str>,
priority: Option<i32>,
status: Option<&str>,
active_form: Option<&str>,
owner: &str,
) -> Result<i64> {
let now = Utc::now();
let status = status.unwrap_or("todo");
let priority = priority.unwrap_or(3);
let result = sqlx::query(
r#"
INSERT INTO tasks (name, spec, priority, status, active_form, first_todo_at, owner)
VALUES (?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(name)
.bind(spec)
.bind(priority)
.bind(status)
.bind(active_form)
.bind(now)
.bind(owner)
.execute(&mut **tx)
.await?;
Ok(result.last_insert_rowid())
}
pub async fn update_task_in_tx(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
task_id: i64,
spec: Option<&str>,
priority: Option<i32>,
status: Option<&str>,
active_form: Option<&str>,
) -> Result<()> {
if let Some(spec) = spec {
sqlx::query("UPDATE tasks SET spec = ? WHERE id = ?")
.bind(spec)
.bind(task_id)
.execute(&mut **tx)
.await?;
}
if let Some(priority) = priority {
sqlx::query("UPDATE tasks SET priority = ? WHERE id = ?")
.bind(priority)
.bind(task_id)
.execute(&mut **tx)
.await?;
}
if let Some(status) = status {
sqlx::query("UPDATE tasks SET status = ? WHERE id = ?")
.bind(status)
.bind(task_id)
.execute(&mut **tx)
.await?;
}
if let Some(active_form) = active_form {
sqlx::query("UPDATE tasks SET active_form = ? WHERE id = ?")
.bind(active_form)
.bind(task_id)
.execute(&mut **tx)
.await?;
}
Ok(())
}
pub async fn set_parent_in_tx(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
task_id: i64,
parent_id: i64,
) -> Result<()> {
sqlx::query("UPDATE tasks SET parent_id = ? WHERE id = ?")
.bind(parent_id)
.bind(task_id)
.execute(&mut **tx)
.await?;
Ok(())
}
pub async fn clear_parent_in_tx(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
task_id: i64,
) -> Result<()> {
sqlx::query("UPDATE tasks SET parent_id = NULL WHERE id = ?")
.bind(task_id)
.execute(&mut **tx)
.await?;
Ok(())
}
pub async fn delete_task_in_tx(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
task_id: i64,
) -> Result<DeleteTaskResult> {
let task_info: Option<(i64,)> =
sqlx::query_as("SELECT id FROM tasks WHERE id = ? AND deleted_at IS NULL")
.bind(task_id)
.fetch_optional(&mut **tx)
.await?;
if task_info.is_none() {
return Ok(DeleteTaskResult {
found: false,
descendant_count: 0,
});
}
let descendant_count = self.count_descendants_in_tx(tx, task_id).await?;
let now = chrono::Utc::now();
sqlx::query(
r#"
WITH RECURSIVE subtree(id) AS (
SELECT id FROM tasks WHERE id = ? AND deleted_at IS NULL
UNION ALL
SELECT t.id FROM tasks t JOIN subtree s ON t.parent_id = s.id
WHERE t.deleted_at IS NULL
)
UPDATE tasks SET deleted_at = ? WHERE id IN (SELECT id FROM subtree)
"#,
)
.bind(task_id)
.bind(now)
.execute(&mut **tx)
.await?;
Ok(DeleteTaskResult {
found: true,
descendant_count,
})
}
async fn count_descendants_in_tx(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
task_id: i64,
) -> Result<i64> {
let count: (i64,) = sqlx::query_as(
r#"
WITH RECURSIVE descendants AS (
SELECT id FROM tasks WHERE parent_id = ? AND deleted_at IS NULL
UNION ALL
SELECT t.id FROM tasks t
INNER JOIN descendants d ON t.parent_id = d.id
WHERE t.deleted_at IS NULL
)
SELECT COUNT(*) FROM descendants
"#,
)
.bind(task_id)
.fetch_one(&mut **tx)
.await?;
Ok(count.0)
}
pub async fn find_focused_in_subtree_in_tx(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
task_id: i64,
) -> Result<Option<(i64, String)>> {
let row: Option<(i64, String)> = sqlx::query_as(
r#"
WITH RECURSIVE subtree AS (
SELECT id FROM tasks WHERE id = ? AND deleted_at IS NULL
UNION ALL
SELECT t.id FROM tasks t
INNER JOIN subtree s ON t.parent_id = s.id
WHERE t.deleted_at IS NULL
)
SELECT s.current_task_id, s.session_id FROM sessions s
WHERE s.current_task_id IN (SELECT id FROM subtree)
LIMIT 1
"#,
)
.bind(task_id)
.fetch_optional(&mut **tx)
.await?;
Ok(row)
}
pub async fn count_incomplete_children_in_tx(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
task_id: i64,
) -> Result<i64> {
let count: (i64,) = sqlx::query_as(crate::sql_constants::COUNT_INCOMPLETE_CHILDREN)
.bind(task_id)
.fetch_one(&mut **tx)
.await?;
Ok(count.0)
}
pub async fn complete_task_in_tx(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
task_id: i64,
) -> Result<()> {
let incomplete_count = self.count_incomplete_children_in_tx(tx, task_id).await?;
if incomplete_count > 0 {
return Err(IntentError::UncompletedChildren);
}
let now = chrono::Utc::now();
sqlx::query(
r#"
UPDATE tasks
SET status = 'done', first_done_at = COALESCE(first_done_at, ?)
WHERE id = ?
"#,
)
.bind(now)
.bind(task_id)
.execute(&mut **tx)
.await?;
Ok(())
}
pub async fn notify_batch_changed(&self) {
if let Some(cli_notifier) = &self.cli_notifier {
cli_notifier
.notify_task_changed(None, "batch_update", self.project_path.clone())
.await;
}
}
#[tracing::instrument(skip(self))]
pub async fn get_task(&self, id: i64) -> Result<Task> {
let task = sqlx::query_as::<_, Task>(
r#"
SELECT id, parent_id, name, spec, status, complexity, priority, first_todo_at, first_doing_at, first_done_at, active_form, owner, metadata
FROM tasks
WHERE id = ? AND deleted_at IS NULL
"#,
)
.bind(id)
.fetch_optional(self.pool)
.await?
.ok_or(IntentError::TaskNotFound(id))?;
Ok(task)
}
pub async fn get_task_with_events(&self, id: i64) -> Result<TaskWithEvents> {
let task = self.get_task(id).await?;
let events_summary = self.get_events_summary(id).await?;
Ok(TaskWithEvents {
task,
events_summary: Some(events_summary),
})
}
pub async fn get_task_ancestry(&self, task_id: i64) -> Result<Vec<Task>> {
let mut chain = Vec::new();
let mut current_id = Some(task_id);
while let Some(id) = current_id {
let task = self.get_task(id).await?;
current_id = task.parent_id;
chain.push(task);
}
Ok(chain)
}
pub async fn get_task_context(&self, id: i64) -> Result<TaskContext> {
let task = self.get_task(id).await?;
let mut ancestors = Vec::new();
let mut current_parent_id = task.parent_id;
while let Some(parent_id) = current_parent_id {
let parent = self.get_task(parent_id).await?;
current_parent_id = parent.parent_id;
ancestors.push(parent);
}
let siblings = self.get_siblings(id, task.parent_id).await?;
let children = self.get_children(id).await?;
let blocking_tasks = self.get_blocking_tasks(id).await?;
let blocked_by_tasks = self.get_blocked_by_tasks(id).await?;
Ok(TaskContext {
task,
ancestors,
siblings,
children,
dependencies: crate::db::models::TaskDependencies {
blocking_tasks,
blocked_by_tasks,
},
})
}
pub async fn get_siblings(&self, id: i64, parent_id: Option<i64>) -> Result<Vec<Task>> {
if let Some(parent_id) = parent_id {
sqlx::query_as::<_, Task>(&format!(
"SELECT {} FROM tasks WHERE parent_id = ? AND id != ? AND deleted_at IS NULL ORDER BY priority ASC NULLS LAST, id ASC",
crate::sql_constants::TASK_COLUMNS
))
.bind(parent_id)
.bind(id)
.fetch_all(self.pool)
.await
.map_err(Into::into)
} else {
sqlx::query_as::<_, Task>(&format!(
"SELECT {} FROM tasks WHERE parent_id IS NULL AND id != ? AND deleted_at IS NULL ORDER BY priority ASC NULLS LAST, id ASC",
crate::sql_constants::TASK_COLUMNS
))
.bind(id)
.fetch_all(self.pool)
.await
.map_err(Into::into)
}
}
pub async fn get_children(&self, id: i64) -> Result<Vec<Task>> {
sqlx::query_as::<_, Task>(&format!(
"SELECT {} FROM tasks WHERE parent_id = ? AND deleted_at IS NULL ORDER BY priority ASC NULLS LAST, id ASC",
crate::sql_constants::TASK_COLUMNS
))
.bind(id)
.fetch_all(self.pool)
.await
.map_err(Into::into)
}
pub async fn get_blocking_tasks(&self, id: i64) -> Result<Vec<Task>> {
sqlx::query_as::<_, Task>(&format!(
"SELECT {} FROM tasks t \
JOIN dependencies d ON t.id = d.blocking_task_id \
WHERE d.blocked_task_id = ? AND t.deleted_at IS NULL \
ORDER BY t.priority ASC NULLS LAST, t.id ASC",
crate::sql_constants::TASK_COLUMNS_PREFIXED
))
.bind(id)
.fetch_all(self.pool)
.await
.map_err(Into::into)
}
pub async fn get_blocked_by_tasks(&self, id: i64) -> Result<Vec<Task>> {
sqlx::query_as::<_, Task>(&format!(
"SELECT {} FROM tasks t \
JOIN dependencies d ON t.id = d.blocked_task_id \
WHERE d.blocking_task_id = ? AND t.deleted_at IS NULL \
ORDER BY t.priority ASC NULLS LAST, t.id ASC",
crate::sql_constants::TASK_COLUMNS_PREFIXED
))
.bind(id)
.fetch_all(self.pool)
.await
.map_err(Into::into)
}
pub async fn get_descendants(&self, task_id: i64) -> Result<Vec<Task>> {
let descendants = sqlx::query_as::<_, Task>(
r#"
WITH RECURSIVE descendants AS (
SELECT id, parent_id, name, spec, status, complexity, priority,
first_todo_at, first_doing_at, first_done_at, active_form, owner, metadata
FROM tasks
WHERE parent_id = ? AND deleted_at IS NULL
UNION ALL
SELECT t.id, t.parent_id, t.name, t.spec, t.status, t.complexity, t.priority,
t.first_todo_at, t.first_doing_at, t.first_done_at, t.active_form, t.owner, t.metadata
FROM tasks t
INNER JOIN descendants d ON t.parent_id = d.id
WHERE t.deleted_at IS NULL
)
SELECT * FROM descendants
ORDER BY parent_id NULLS FIRST, priority ASC NULLS LAST, id ASC
"#,
)
.bind(task_id)
.fetch_all(self.pool)
.await?;
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 context = self.get_task_context(task_id).await?;
let descendants_full = self.get_descendants(task_id).await?;
let siblings: Vec<TaskBrief> = context.siblings.iter().map(TaskBrief::from).collect();
let descendants: Vec<TaskBrief> = descendants_full.iter().map(TaskBrief::from).collect();
let events = if with_events {
let event_mgr = crate::events::EventManager::new(self.pool);
Some(
event_mgr
.list_events(Some(task_id), Some(50), None, None)
.await?,
)
} else {
None
};
Ok(StatusResponse {
focused_task: context.task,
ancestors: context.ancestors,
siblings,
descendants,
events,
})
}
pub async fn get_root_tasks(&self) -> Result<Vec<Task>> {
let tasks = sqlx::query_as::<_, Task>(
r#"
SELECT id, parent_id, name, spec, status, complexity, priority,
first_todo_at, first_doing_at, first_done_at, active_form, owner, metadata
FROM tasks
WHERE parent_id IS NULL AND deleted_at IS NULL
ORDER BY
CASE status
WHEN 'doing' THEN 0
WHEN 'todo' THEN 1
WHEN 'done' THEN 2
END,
priority ASC NULLS LAST,
id ASC
"#,
)
.fetch_all(self.pool)
.await?;
Ok(tasks)
}
async fn get_events_summary(&self, task_id: i64) -> Result<EventsSummary> {
let total_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM events WHERE task_id = ?")
.bind(task_id)
.fetch_one(self.pool)
.await?;
let recent_events = sqlx::query_as::<_, Event>(
r#"
SELECT id, task_id, timestamp, log_type, discussion_data
FROM events
WHERE task_id = ?
ORDER BY timestamp DESC
LIMIT 10
"#,
)
.bind(task_id)
.fetch_all(self.pool)
.await?;
Ok(EventsSummary {
total_count,
recent_events,
})
}
pub async fn update_task(&self, id: i64, update: TaskUpdate<'_>) -> Result<Task> {
let TaskUpdate {
name,
spec,
parent_id,
status,
complexity,
priority,
active_form,
owner,
metadata,
} = update;
let task = self.get_task(id).await?;
let status = if let Some(s) = status {
match crate::plan::TaskStatus::from_db_str(s) {
Some(ts) => Some(ts.as_db_str()),
None => return Err(IntentError::InvalidInput(format!("Invalid status: {}", s))),
}
} else {
None
};
if let Some(Some(pid)) = 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 builder: sqlx::QueryBuilder<sqlx::Sqlite> =
sqlx::QueryBuilder::new("UPDATE tasks SET ");
let mut has_updates = false;
if let Some(n) = name {
if has_updates {
builder.push(", ");
}
builder.push("name = ").push_bind(n);
has_updates = true;
}
if let Some(s) = spec {
if has_updates {
builder.push(", ");
}
builder.push("spec = ").push_bind(s);
has_updates = true;
}
if let Some(pid) = parent_id {
if has_updates {
builder.push(", ");
}
match pid {
Some(p) => {
builder.push("parent_id = ").push_bind(p);
},
None => {
builder.push("parent_id = NULL");
},
}
has_updates = true;
}
if let Some(c) = complexity {
if has_updates {
builder.push(", ");
}
builder.push("complexity = ").push_bind(c);
has_updates = true;
}
if let Some(p) = priority {
if has_updates {
builder.push(", ");
}
builder.push("priority = ").push_bind(p);
has_updates = true;
}
if let Some(af) = active_form {
if has_updates {
builder.push(", ");
}
builder.push("active_form = ").push_bind(af);
has_updates = true;
}
if let Some(o) = owner {
if o.is_empty() {
return Err(IntentError::InvalidInput(
"owner cannot be empty".to_string(),
));
}
if has_updates {
builder.push(", ");
}
builder.push("owner = ").push_bind(o);
has_updates = true;
}
if let Some(m) = metadata {
if has_updates {
builder.push(", ");
}
builder.push("metadata = ").push_bind(m);
has_updates = true;
}
if let Some(s) = status {
if has_updates {
builder.push(", ");
}
builder.push("status = ").push_bind(s);
has_updates = true;
let now = Utc::now();
let timestamp = now.to_rfc3339();
match s {
"todo" if task.first_todo_at.is_none() => {
builder.push(", first_todo_at = ").push_bind(timestamp);
},
"doing" if task.first_doing_at.is_none() => {
builder.push(", first_doing_at = ").push_bind(timestamp);
},
"done" if task.first_done_at.is_none() => {
builder.push(", first_done_at = ").push_bind(timestamp);
},
_ => {},
}
}
if !has_updates {
return Ok(task);
}
builder.push(" WHERE id = ").push_bind(id);
builder.build().execute(self.pool).await?;
let task = self.get_task(id).await?;
self.notify_task_updated(&task).await;
Ok(task)
}
pub async fn delete_task(&self, id: i64) -> Result<()> {
self.check_task_exists(id).await?;
if let Some((_, sid)) = self.find_focused_in_set(&[id]).await? {
return Err(IntentError::ActionNotAllowed(format!(
"Task #{} is focused by session '{}'. Unfocus it first.",
id, sid
)));
}
let now = chrono::Utc::now();
sqlx::query("UPDATE tasks SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL")
.bind(now)
.bind(id)
.execute(self.pool)
.await?;
self.notify_task_deleted(id).await;
Ok(())
}
pub async fn delete_task_cascade(&self, id: i64) -> Result<usize> {
let mut tx = self.pool.begin().await?;
let subtree_ids = self.get_subtree_ids_in_tx(&mut tx, id).await?;
if let Some((tid, sid)) = self.find_focused_in_subtree_in_tx(&mut tx, id).await? {
return Err(IntentError::ActionNotAllowed(format!(
"Cannot cascade delete: task #{} is focused by session '{}'. Unfocus it first.",
tid, sid
)));
}
let delete_result = self.delete_task_in_tx(&mut tx, id).await?;
if !delete_result.found {
return Err(IntentError::TaskNotFound(id));
}
tx.commit().await?;
for &deleted_id in &subtree_ids {
self.notify_task_deleted(deleted_id).await;
}
Ok(delete_result.descendant_count as usize)
}
async fn get_subtree_ids_in_tx(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
task_id: i64,
) -> Result<Vec<i64>> {
let mut ids: Vec<i64> = sqlx::query_scalar(
r#"
WITH RECURSIVE descendants AS (
SELECT id FROM tasks WHERE parent_id = ? AND deleted_at IS NULL
UNION ALL
SELECT t.id FROM tasks t
INNER JOIN descendants d ON t.parent_id = d.id
WHERE t.deleted_at IS NULL
)
SELECT id FROM descendants
ORDER BY id ASC
"#,
)
.bind(task_id)
.fetch_all(&mut **tx)
.await?;
ids.push(task_id);
Ok(ids)
}
async fn find_focused_in_set(&self, task_ids: &[i64]) -> Result<Option<(i64, String)>> {
if task_ids.is_empty() {
return Ok(None);
}
let placeholders: Vec<&str> = task_ids.iter().map(|_| "?").collect();
let sql = format!(
"SELECT current_task_id, session_id FROM sessions WHERE current_task_id IN ({}) LIMIT 1",
placeholders.join(", ")
);
let mut query = sqlx::query_as::<_, (i64, String)>(&sql);
for id in task_ids {
query = query.bind(id);
}
Ok(query.fetch_optional(self.pool).await?)
}
pub async fn add_dependency(&self, blocking_id: i64, blocked_id: i64) -> Result<()> {
crate::dependencies::add_dependency(self.pool, blocking_id, blocked_id)
.await
.map(|_| ())
}
pub async fn remove_dependency(&self, blocking_id: i64, blocked_id: i64) -> Result<()> {
sqlx::query("DELETE FROM dependencies WHERE blocking_task_id = ? AND blocked_task_id = ?")
.bind(blocking_id)
.bind(blocked_id)
.execute(self.pool)
.await?;
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 session_id = crate::workspace::resolve_session_id(None);
let mut where_clause = String::from("WHERE deleted_at IS NULL");
let mut conditions = Vec::new();
if let Some(s) = status {
let canonical = match crate::plan::TaskStatus::from_db_str(&s) {
Some(ts) => ts.as_db_str(),
None => return Err(IntentError::InvalidInput(format!("Invalid status: {}", s))),
};
where_clause.push_str(" AND status = ?");
conditions.push(canonical.to_string());
}
if let Some(pid) = parent_id {
if let Some(p) = pid {
where_clause.push_str(" AND parent_id = ?");
conditions.push(p.to_string());
} else {
where_clause.push_str(" AND parent_id IS NULL");
}
}
let uses_session_bind = matches!(sort_by, TaskSortBy::FocusAware);
let order_clause = match sort_by {
TaskSortBy::Id => {
"ORDER BY id ASC".to_string()
},
TaskSortBy::Priority => {
"ORDER BY COALESCE(priority, 999) ASC, COALESCE(complexity, 5) ASC, id ASC"
.to_string()
},
TaskSortBy::Time => {
r#"ORDER BY
CASE status
WHEN 'doing' THEN first_doing_at
WHEN 'todo' THEN first_todo_at
WHEN 'done' THEN first_done_at
END ASC NULLS LAST,
id ASC"#
.to_string()
},
TaskSortBy::FocusAware => {
r#"ORDER BY
CASE
WHEN t.id = (SELECT current_task_id FROM sessions WHERE session_id = ?) THEN 0
WHEN t.status = 'doing' THEN 1
WHEN t.status = 'todo' THEN 2
ELSE 3
END ASC,
COALESCE(t.priority, 999) ASC,
t.id ASC"#
.to_string()
},
};
let count_query = format!("SELECT COUNT(*) FROM tasks {}", where_clause);
let mut count_q = sqlx::query_scalar::<_, i64>(&count_query);
for cond in &conditions {
count_q = count_q.bind(cond);
}
let total_count = count_q.fetch_one(self.pool).await?;
let main_query = format!(
"SELECT id, parent_id, name, NULL as spec, status, complexity, priority, first_todo_at, first_doing_at, first_done_at, active_form, owner, metadata FROM tasks t {} {} LIMIT ? OFFSET ?",
where_clause, order_clause
);
let mut q = sqlx::query_as::<_, Task>(&main_query);
for cond in conditions {
q = q.bind(cond);
}
if uses_session_bind {
q = q.bind(&session_id);
}
q = q.bind(limit);
q = q.bind(offset);
let tasks = q.fetch_all(self.pool).await?;
let has_more = offset + (tasks.len() as i64) < total_count;
Ok(PaginatedTasks {
tasks,
total_count,
has_more,
limit,
offset,
})
}
pub async fn get_stats(&self) -> Result<WorkspaceStats> {
let row = sqlx::query_as::<_, (i64, i64, i64, i64)>(
r#"SELECT
COUNT(*) as total,
COALESCE(SUM(CASE WHEN status = 'todo' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status = 'doing' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END), 0)
FROM tasks WHERE deleted_at IS NULL"#,
)
.fetch_one(self.pool)
.await?;
Ok(WorkspaceStats {
total_tasks: row.0,
todo: row.1,
doing: row.2,
done: row.3,
})
}
#[tracing::instrument(skip(self))]
pub async fn start_task(&self, id: i64, with_events: bool) -> Result<TaskWithEvents> {
let task_exists: bool =
sqlx::query_scalar::<_, bool>(crate::sql_constants::CHECK_TASK_EXISTS)
.bind(id)
.fetch_one(self.pool)
.await?;
if !task_exists {
return Err(IntentError::TaskNotFound(id));
}
use crate::dependencies::get_incomplete_blocking_tasks;
if let Some(blocking_tasks) = get_incomplete_blocking_tasks(self.pool, id).await? {
return Err(IntentError::TaskBlocked {
task_id: id,
blocking_task_ids: blocking_tasks,
});
}
let mut tx = self.pool.begin().await?;
let now = Utc::now();
sqlx::query(
r#"
UPDATE tasks
SET status = 'doing', first_doing_at = COALESCE(first_doing_at, ?)
WHERE id = ?
"#,
)
.bind(now)
.bind(id)
.execute(&mut *tx)
.await?;
let session_id = crate::workspace::resolve_session_id(None);
sqlx::query(
r#"
INSERT INTO sessions (session_id, current_task_id, created_at, last_active_at)
VALUES (?, ?, datetime('now'), datetime('now'))
ON CONFLICT(session_id) DO UPDATE SET
current_task_id = excluded.current_task_id,
last_active_at = datetime('now')
"#,
)
.bind(&session_id)
.bind(id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
if with_events {
let result = self.get_task_with_events(id).await?;
self.notify_task_updated(&result.task).await;
Ok(result)
} else {
let task = self.get_task(id).await?;
self.notify_task_updated(&task).await;
Ok(TaskWithEvents {
task,
events_summary: None,
})
}
}
async fn build_next_step_suggestion(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
id: i64,
task_name: &str,
parent_id: Option<i64>,
) -> Result<NextStepSuggestion> {
if let Some(parent_task_id) = parent_id {
let remaining_siblings: i64 = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM tasks WHERE parent_id = ? AND status != 'done' AND id != ? AND deleted_at IS NULL",
)
.bind(parent_task_id)
.bind(id)
.fetch_one(&mut **tx)
.await?;
let parent_name: String =
sqlx::query_scalar::<_, String>(crate::sql_constants::SELECT_TASK_NAME)
.bind(parent_task_id)
.fetch_one(&mut **tx)
.await?;
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: i64 =
sqlx::query_scalar::<_, i64>(crate::sql_constants::COUNT_CHILDREN_TOTAL)
.bind(id)
.fetch_one(&mut **tx)
.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 remaining_tasks: i64 = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM tasks WHERE status != 'done' AND id != ? AND deleted_at IS NULL",
)
.bind(id)
.fetch_one(&mut **tx)
.await?;
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(),
})
}
}
}
}
#[tracing::instrument(skip(self))]
pub async fn done_task(&self, is_ai_caller: bool) -> Result<DoneTaskResponse> {
let session_id = crate::workspace::resolve_session_id(None);
let mut tx = self.pool.begin().await?;
let current_task_id: Option<i64> = sqlx::query_scalar::<_, Option<i64>>(
"SELECT current_task_id FROM sessions WHERE session_id = ?",
)
.bind(&session_id)
.fetch_optional(&mut *tx)
.await?
.flatten();
let id = current_task_id.ok_or(IntentError::InvalidInput(
"No current task is set. Use 'current --set <ID>' to set a task first.".to_string(),
))?;
let task_info: (String, Option<i64>, String) =
sqlx::query_as("SELECT name, parent_id, owner FROM tasks WHERE id = ?")
.bind(id)
.fetch_one(&mut *tx)
.await?;
let (task_name, parent_id, owner) = task_info;
if owner == "human" && is_ai_caller {
return Err(IntentError::HumanTaskCannotBeCompletedByAI {
task_id: id,
task_name: task_name.clone(),
});
}
self.complete_task_in_tx(&mut tx, id).await?;
sqlx::query("UPDATE sessions SET current_task_id = NULL, last_active_at = datetime('now') WHERE session_id = ?")
.bind(&session_id)
.execute(&mut *tx)
.await?;
let next_step_suggestion =
Self::build_next_step_suggestion(&mut tx, id, &task_name, parent_id).await?;
tx.commit().await?;
let completed_task = self.get_task(id).await?;
self.notify_task_updated(&completed_task).await;
Ok(DoneTaskResponse {
completed_task,
workspace_status: WorkspaceStatus {
current_task_id: None,
},
next_step_suggestion,
})
}
#[tracing::instrument(skip(self))]
pub async fn done_task_by_id(&self, id: i64, is_ai_caller: bool) -> Result<DoneTaskResponse> {
let session_id = crate::workspace::resolve_session_id(None);
let mut tx = self.pool.begin().await?;
let task_info: (String, Option<i64>, String) = sqlx::query_as(
"SELECT name, parent_id, owner FROM tasks WHERE id = ? AND deleted_at IS NULL",
)
.bind(id)
.fetch_optional(&mut *tx)
.await?
.ok_or(IntentError::TaskNotFound(id))?;
let (task_name, parent_id, owner) = task_info;
if owner == "human" && is_ai_caller {
return Err(IntentError::HumanTaskCannotBeCompletedByAI {
task_id: id,
task_name: task_name.clone(),
});
}
self.complete_task_in_tx(&mut tx, id).await?;
sqlx::query(
"UPDATE sessions SET current_task_id = NULL, last_active_at = datetime('now') WHERE session_id = ? AND current_task_id = ?",
)
.bind(&session_id)
.bind(id)
.execute(&mut *tx)
.await?;
let actual_current_task_id: Option<i64> = sqlx::query_scalar::<_, Option<i64>>(
"SELECT current_task_id FROM sessions WHERE session_id = ?",
)
.bind(&session_id)
.fetch_optional(&mut *tx)
.await?
.flatten();
let next_step_suggestion =
Self::build_next_step_suggestion(&mut tx, id, &task_name, parent_id).await?;
let synthesis_result = self.try_synthesize_task_description(id, &task_name).await;
tx.commit().await?;
let mut completed_task = self.get_task(id).await?;
if let Ok(Some(new_spec)) = synthesis_result {
completed_task = self
.apply_synthesis_if_appropriate(completed_task, &new_spec, &owner)
.await?;
}
crate::llm::analyze_task_structure_background(self.pool.clone());
self.notify_task_updated(&completed_task).await;
Ok(DoneTaskResponse {
completed_task,
workspace_status: WorkspaceStatus {
current_task_id: actual_current_task_id,
},
next_step_suggestion,
})
}
async fn check_task_exists(&self, id: i64) -> Result<()> {
let exists: bool = sqlx::query_scalar::<_, bool>(crate::sql_constants::CHECK_TASK_EXISTS)
.bind(id)
.fetch_one(self.pool)
.await?;
if !exists {
return Err(IntentError::TaskNotFound(id));
}
Ok(())
}
async fn check_circular_dependency(&self, task_id: i64, new_parent_id: i64) -> Result<()> {
let mut current_id = new_parent_id;
loop {
if current_id == task_id {
return Err(IntentError::CircularDependency {
blocking_task_id: new_parent_id,
blocked_task_id: task_id,
});
}
let parent: Option<i64> =
sqlx::query_scalar::<_, Option<i64>>(crate::sql_constants::SELECT_TASK_PARENT_ID)
.bind(current_id)
.fetch_optional(self.pool)
.await?
.flatten();
match parent {
Some(pid) => current_id = pid,
None => break,
}
}
Ok(())
}
pub async fn spawn_subtask(
&self,
name: &str,
spec: Option<&str>,
) -> Result<SpawnSubtaskResponse> {
let session_id = crate::workspace::resolve_session_id(None);
let current_task_id: Option<i64> = sqlx::query_scalar::<_, Option<i64>>(
"SELECT current_task_id FROM sessions WHERE session_id = ?",
)
.bind(&session_id)
.fetch_optional(self.pool)
.await?
.flatten();
let parent_id = current_task_id.ok_or(IntentError::InvalidInput(
"No current task to create subtask under".to_string(),
))?;
let parent_name: String =
sqlx::query_scalar::<_, String>(crate::sql_constants::SELECT_TASK_NAME)
.bind(parent_id)
.fetch_one(self.pool)
.await?;
let subtask = self
.add_task(
name.to_string(),
spec.map(|s| s.to_string()),
Some(parent_id),
Some("ai".to_string()),
None,
None,
)
.await?;
self.start_task(subtask.id, false).await?;
Ok(SpawnSubtaskResponse {
subtask: SubtaskInfo {
id: subtask.id,
name: subtask.name,
parent_id,
status: "doing".to_string(),
},
parent_task: ParentTaskInfo {
id: parent_id,
name: parent_name,
},
})
}
pub async fn pick_next_tasks(
&self,
max_count: usize,
capacity_limit: usize,
) -> Result<Vec<Task>> {
let mut tx = self.pool.begin().await?;
let doing_count: i64 =
sqlx::query_scalar::<_, i64>(crate::sql_constants::COUNT_TASKS_DOING)
.fetch_one(&mut *tx)
.await?;
let available = capacity_limit.saturating_sub(doing_count as usize);
if available == 0 {
return Ok(vec![]);
}
let limit = std::cmp::min(max_count, available);
let todo_tasks = sqlx::query_as::<_, Task>(
r#"
SELECT id, parent_id, name, spec, status, complexity, priority, first_todo_at, first_doing_at, first_done_at, active_form, owner, metadata
FROM tasks
WHERE status = 'todo' AND deleted_at IS NULL
ORDER BY
COALESCE(priority, 0) ASC,
COALESCE(complexity, 5) ASC,
id ASC
LIMIT ?
"#,
)
.bind(limit as i64)
.fetch_all(&mut *tx)
.await?;
if todo_tasks.is_empty() {
return Ok(vec![]);
}
let now = Utc::now();
for task in &todo_tasks {
sqlx::query(
r#"
UPDATE tasks
SET status = 'doing',
first_doing_at = COALESCE(first_doing_at, ?)
WHERE id = ?
"#,
)
.bind(now)
.bind(task.id)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
let task_ids: Vec<i64> = todo_tasks.iter().map(|t| t.id).collect();
let placeholders = vec!["?"; task_ids.len()].join(",");
let query = format!(
"SELECT id, parent_id, name, spec, status, complexity, priority, first_todo_at, first_doing_at, first_done_at, active_form, owner, metadata
FROM tasks WHERE id IN ({})
ORDER BY
COALESCE(priority, 0) ASC,
COALESCE(complexity, 5) ASC,
id ASC",
placeholders
);
let mut q = sqlx::query_as::<_, Task>(&query);
for id in task_ids {
q = q.bind(id);
}
let updated_tasks = q.fetch_all(self.pool).await?;
Ok(updated_tasks)
}
pub async fn pick_next(&self) -> Result<PickNextResponse> {
let session_id = crate::workspace::resolve_session_id(None);
let current_task_id: Option<i64> = sqlx::query_scalar::<_, Option<i64>>(
"SELECT current_task_id FROM sessions WHERE session_id = ?",
)
.bind(&session_id)
.fetch_optional(self.pool)
.await?
.flatten();
if let Some(current_id) = current_task_id {
let doing_subtasks = sqlx::query_as::<_, Task>(
r#"
SELECT id, parent_id, name, spec, status, complexity, priority,
first_todo_at, first_doing_at, first_done_at, active_form, owner, metadata
FROM tasks
WHERE parent_id = ? AND status = 'doing' AND deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM dependencies d
JOIN tasks bt ON d.blocking_task_id = bt.id
WHERE d.blocked_task_id = tasks.id
AND bt.status != 'done' AND bt.deleted_at IS NULL
)
ORDER BY COALESCE(priority, 999999) ASC, id ASC
LIMIT 1
"#,
)
.bind(current_id)
.fetch_optional(self.pool)
.await?;
if let Some(task) = doing_subtasks {
return Ok(PickNextResponse::focused_subtask(task));
}
let todo_subtasks = sqlx::query_as::<_, Task>(
r#"
SELECT id, parent_id, name, spec, status, complexity, priority,
first_todo_at, first_doing_at, first_done_at, active_form, owner, metadata
FROM tasks
WHERE parent_id = ? AND status = 'todo' AND deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM dependencies d
JOIN tasks bt ON d.blocking_task_id = bt.id
WHERE d.blocked_task_id = tasks.id
AND bt.status != 'done' AND bt.deleted_at IS NULL
)
ORDER BY COALESCE(priority, 999999) ASC, id ASC
LIMIT 1
"#,
)
.bind(current_id)
.fetch_optional(self.pool)
.await?;
if let Some(task) = todo_subtasks {
return Ok(PickNextResponse::focused_subtask(task));
}
}
let doing_top_level = if let Some(current_id) = current_task_id {
sqlx::query_as::<_, Task>(
r#"
SELECT id, parent_id, name, spec, status, complexity, priority,
first_todo_at, first_doing_at, first_done_at, active_form, owner, metadata
FROM tasks
WHERE parent_id IS NULL AND status = 'doing' AND id != ? AND deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM dependencies d
JOIN tasks bt ON d.blocking_task_id = bt.id
WHERE d.blocked_task_id = tasks.id
AND bt.status != 'done' AND bt.deleted_at IS NULL
)
ORDER BY COALESCE(priority, 999999) ASC, id ASC
LIMIT 1
"#,
)
.bind(current_id)
.fetch_optional(self.pool)
.await?
} else {
sqlx::query_as::<_, Task>(
r#"
SELECT id, parent_id, name, spec, status, complexity, priority,
first_todo_at, first_doing_at, first_done_at, active_form, owner, metadata
FROM tasks
WHERE parent_id IS NULL AND status = 'doing' AND deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM dependencies d
JOIN tasks bt ON d.blocking_task_id = bt.id
WHERE d.blocked_task_id = tasks.id
AND bt.status != 'done' AND bt.deleted_at IS NULL
)
ORDER BY COALESCE(priority, 999999) ASC, id ASC
LIMIT 1
"#,
)
.fetch_optional(self.pool)
.await?
};
if let Some(task) = doing_top_level {
return Ok(PickNextResponse::top_level_task(task));
}
let todo_top_level = sqlx::query_as::<_, Task>(
r#"
SELECT id, parent_id, name, spec, status, complexity, priority,
first_todo_at, first_doing_at, first_done_at, active_form, owner, metadata
FROM tasks
WHERE parent_id IS NULL AND status = 'todo' AND deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM dependencies d
JOIN tasks bt ON d.blocking_task_id = bt.id
WHERE d.blocked_task_id = tasks.id
AND bt.status != 'done' AND bt.deleted_at IS NULL
)
ORDER BY COALESCE(priority, 999999) ASC, id ASC
LIMIT 1
"#,
)
.fetch_optional(self.pool)
.await?;
if let Some(task) = todo_top_level {
return Ok(PickNextResponse::top_level_task(task));
}
let total_tasks: i64 =
sqlx::query_scalar::<_, i64>(crate::sql_constants::COUNT_TASKS_TOTAL)
.fetch_one(self.pool)
.await?;
if total_tasks == 0 {
return Ok(PickNextResponse::no_tasks_in_project());
}
let todo_or_doing_count: i64 = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM tasks WHERE status IN ('todo', 'doing') AND deleted_at IS NULL",
)
.fetch_one(self.pool)
.await?;
if todo_or_doing_count == 0 {
return Ok(PickNextResponse::all_tasks_completed());
}
Ok(PickNextResponse::no_available_todos())
}
async fn try_synthesize_task_description(
&self,
task_id: i64,
task_name: &str,
) -> Result<Option<String>> {
let task = self.get_task(task_id).await?;
let events = crate::events::EventManager::new(self.pool)
.list_events(Some(task_id), None, None, None)
.await?;
match crate::llm::synthesize_task_description(
self.pool,
task_name,
task.spec.as_deref(),
&events,
)
.await
{
Ok(synthesis) => Ok(synthesis),
Err(e) => {
tracing::warn!("LLM synthesis failed: {}", e);
Ok(None)
},
}
}
async fn apply_synthesis_if_appropriate(
&self,
task: Task,
new_spec: &str,
owner: &str,
) -> Result<Task> {
if owner == "ai" {
tracing::info!("Auto-applying LLM synthesis for AI-owned task #{}", task.id);
let updated = self
.update_task(
task.id,
TaskUpdate {
spec: Some(new_spec),
..Default::default()
},
)
.await?;
Ok(updated)
} else {
tracing::info!(
"LLM synthesis available for human-owned task #{}, but auto-apply disabled. \
User would be prompted in interactive mode.",
task.id
);
eprintln!("\n💡 LLM generated a task summary:");
eprintln!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
eprintln!("{}", new_spec);
eprintln!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
eprintln!("(Auto-apply disabled for human-owned tasks)");
eprintln!(
"To apply manually: ie task update {} --description \"<new spec>\"",
task.id
);
Ok(task) }
}
}
impl crate::backend::TaskBackend for TaskManager<'_> {
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<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: 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 TaskManager<'_> {
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::search::SearchManager;
let mgr = SearchManager::new(self.pool);
async move {
mgr.search(&query, include_tasks, include_events, limit, offset, false)
.await
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::events::EventManager;
use crate::test_utils::test_helpers::TestContext;
use crate::workspace::WorkspaceManager;
#[tokio::test]
async fn test_get_stats_empty() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let stats = manager.get_stats().await.unwrap();
assert_eq!(stats.total_tasks, 0);
assert_eq!(stats.todo, 0);
assert_eq!(stats.doing, 0);
assert_eq!(stats.done, 0);
}
#[tokio::test]
async fn test_get_stats_with_tasks() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let task1 = manager
.add_task("Task 1".to_string(), None, None, None, None, None)
.await
.unwrap();
let task2 = manager
.add_task("Task 2".to_string(), None, None, None, None, None)
.await
.unwrap();
let _task3 = manager
.add_task("Task 3".to_string(), None, None, None, None, None)
.await
.unwrap();
manager
.update_task(
task1.id,
TaskUpdate {
status: Some("doing"),
..Default::default()
},
)
.await
.unwrap();
manager
.update_task(
task2.id,
TaskUpdate {
status: Some("done"),
..Default::default()
},
)
.await
.unwrap();
let stats = manager.get_stats().await.unwrap();
assert_eq!(stats.total_tasks, 3);
assert_eq!(stats.todo, 1);
assert_eq!(stats.doing, 1);
assert_eq!(stats.done, 1);
}
#[tokio::test]
async fn test_add_task() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let task = manager
.add_task("Test task".to_string(), None, None, None, None, None)
.await
.unwrap();
assert_eq!(task.name, "Test task");
assert_eq!(task.status, "todo");
assert!(task.first_todo_at.is_some());
assert!(task.first_doing_at.is_none());
assert!(task.first_done_at.is_none());
}
#[tokio::test]
async fn test_add_task_with_spec() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let spec = "This is a task specification";
let task = manager
.add_task(
"Test task".to_string(),
Some(spec.to_string()),
None,
None,
None,
None,
)
.await
.unwrap();
assert_eq!(task.name, "Test task");
assert_eq!(task.spec.as_deref(), Some(spec));
}
#[tokio::test]
async fn test_add_task_with_parent() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let parent = manager
.add_task("Parent task".to_string(), None, None, None, None, None)
.await
.unwrap();
let child = manager
.add_task(
"Child task".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
assert_eq!(child.parent_id, Some(parent.id));
}
#[tokio::test]
async fn test_get_task() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let created = manager
.add_task("Test task".to_string(), None, None, None, None, None)
.await
.unwrap();
let retrieved = manager.get_task(created.id).await.unwrap();
assert_eq!(created.id, retrieved.id);
assert_eq!(created.name, retrieved.name);
}
#[tokio::test]
async fn test_get_task_not_found() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let result = manager.get_task(999).await;
assert!(matches!(result, Err(IntentError::TaskNotFound(999))));
}
#[tokio::test]
async fn test_update_task_name() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let task = manager
.add_task("Original name".to_string(), None, None, None, None, None)
.await
.unwrap();
let updated = manager
.update_task(
task.id,
TaskUpdate {
name: Some("New name"),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.name, "New name");
}
#[tokio::test]
async fn test_update_task_status() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let task = manager
.add_task("Test task".to_string(), None, None, None, None, None)
.await
.unwrap();
let updated = manager
.update_task(
task.id,
TaskUpdate {
status: Some("doing"),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.status, "doing");
assert!(updated.first_doing_at.is_some());
}
#[tokio::test]
async fn test_delete_task() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let task = manager
.add_task("Test task".to_string(), None, None, None, None, None)
.await
.unwrap();
manager.delete_task(task.id).await.unwrap();
let result = manager.get_task(task.id).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_find_tasks_by_status() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
manager
.add_task("Todo task".to_string(), None, None, None, None, None)
.await
.unwrap();
let doing_task = manager
.add_task("Doing task".to_string(), None, None, None, None, None)
.await
.unwrap();
manager
.update_task(
doing_task.id,
TaskUpdate {
status: Some("doing"),
..Default::default()
},
)
.await
.unwrap();
let todo_result = manager
.find_tasks(Some("todo".to_string()), None, None, None, None)
.await
.unwrap();
let doing_result = manager
.find_tasks(Some("doing".to_string()), None, None, None, None)
.await
.unwrap();
assert_eq!(todo_result.tasks.len(), 1);
assert_eq!(doing_result.tasks.len(), 1);
assert_eq!(doing_result.tasks[0].status, "doing");
}
#[tokio::test]
async fn test_find_tasks_by_parent() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let parent = manager
.add_task("Parent".to_string(), None, None, None, None, None)
.await
.unwrap();
manager
.add_task(
"Child 1".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
manager
.add_task(
"Child 2".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
let result = manager
.find_tasks(None, Some(Some(parent.id)), None, None, None)
.await
.unwrap();
assert_eq!(result.tasks.len(), 2);
}
#[tokio::test]
async fn test_start_task() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let task = manager
.add_task("Test task".to_string(), None, None, None, None, None)
.await
.unwrap();
let started = manager.start_task(task.id, false).await.unwrap();
assert_eq!(started.task.status, "doing");
assert!(started.task.first_doing_at.is_some());
let session_id = crate::workspace::resolve_session_id(None);
let current: Option<i64> = sqlx::query_scalar::<_, Option<i64>>(
"SELECT current_task_id FROM sessions WHERE session_id = ?",
)
.bind(&session_id)
.fetch_optional(ctx.pool())
.await
.unwrap()
.flatten();
assert_eq!(current, Some(task.id));
}
#[tokio::test]
async fn test_start_task_with_events() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let task = manager
.add_task("Test task".to_string(), None, None, None, None, None)
.await
.unwrap();
sqlx::query("INSERT INTO events (task_id, log_type, discussion_data) VALUES (?, ?, ?)")
.bind(task.id)
.bind("test")
.bind("test event")
.execute(ctx.pool())
.await
.unwrap();
let started = manager.start_task(task.id, true).await.unwrap();
assert!(started.events_summary.is_some());
let summary = started.events_summary.unwrap();
assert_eq!(summary.total_count, 1);
}
#[tokio::test]
async fn test_done_task() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let task = manager
.add_task("Test task".to_string(), None, None, None, None, None)
.await
.unwrap();
manager.start_task(task.id, false).await.unwrap();
let response = manager.done_task(false).await.unwrap();
assert_eq!(response.completed_task.status, "done");
assert!(response.completed_task.first_done_at.is_some());
assert_eq!(response.workspace_status.current_task_id, None);
match response.next_step_suggestion {
NextStepSuggestion::WorkspaceIsClear { .. } => {},
_ => panic!("Expected WorkspaceIsClear suggestion"),
}
let session_id = crate::workspace::resolve_session_id(None);
let current: Option<i64> = sqlx::query_scalar::<_, Option<i64>>(
"SELECT current_task_id FROM sessions WHERE session_id = ?",
)
.bind(&session_id)
.fetch_optional(ctx.pool())
.await
.unwrap()
.flatten();
assert!(current.is_none());
}
#[tokio::test]
async fn test_done_task_with_uncompleted_children() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let parent = manager
.add_task("Parent".to_string(), None, None, None, None, None)
.await
.unwrap();
manager
.add_task("Child".to_string(), None, Some(parent.id), None, None, None)
.await
.unwrap();
manager.start_task(parent.id, false).await.unwrap();
let result = manager.done_task(false).await;
assert!(matches!(result, Err(IntentError::UncompletedChildren)));
}
#[tokio::test]
async fn test_done_task_with_completed_children() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let parent = manager
.add_task("Parent".to_string(), None, None, None, None, None)
.await
.unwrap();
let child = manager
.add_task("Child".to_string(), None, Some(parent.id), None, None, None)
.await
.unwrap();
manager.start_task(child.id, false).await.unwrap();
let child_response = manager.done_task(false).await.unwrap();
match child_response.next_step_suggestion {
NextStepSuggestion::ParentIsReady { parent_task_id, .. } => {
assert_eq!(parent_task_id, parent.id);
},
_ => panic!("Expected ParentIsReady suggestion"),
}
manager.start_task(parent.id, false).await.unwrap();
let parent_response = manager.done_task(false).await.unwrap();
assert_eq!(parent_response.completed_task.status, "done");
match parent_response.next_step_suggestion {
NextStepSuggestion::TopLevelTaskCompleted { .. } => {},
_ => panic!("Expected TopLevelTaskCompleted suggestion"),
}
}
#[tokio::test]
async fn test_circular_dependency() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let task1 = manager
.add_task("Task 1".to_string(), None, None, None, None, None)
.await
.unwrap();
let task2 = manager
.add_task("Task 2".to_string(), None, Some(task1.id), None, None, None)
.await
.unwrap();
let result = manager
.update_task(
task1.id,
TaskUpdate {
parent_id: Some(Some(task2.id)),
..Default::default()
},
)
.await;
assert!(matches!(
result,
Err(IntentError::CircularDependency { .. })
));
}
#[tokio::test]
async fn test_invalid_parent_id() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let result = manager
.add_task("Test".to_string(), None, Some(999), None, None, None)
.await;
assert!(matches!(result, Err(IntentError::TaskNotFound(999))));
}
#[tokio::test]
async fn test_update_task_complexity_and_priority() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let task = manager
.add_task("Test task".to_string(), None, None, None, None, None)
.await
.unwrap();
let updated = manager
.update_task(
task.id,
TaskUpdate {
complexity: Some(8),
priority: Some(10),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.complexity, Some(8));
assert_eq!(updated.priority, Some(10));
}
#[tokio::test]
async fn test_spawn_subtask() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let parent = manager
.add_task("Parent task".to_string(), None, None, None, None, None)
.await
.unwrap();
manager.start_task(parent.id, false).await.unwrap();
let response = manager
.spawn_subtask("Child task", Some("Details"))
.await
.unwrap();
assert_eq!(response.subtask.parent_id, parent.id);
assert_eq!(response.subtask.name, "Child task");
assert_eq!(response.subtask.status, "doing");
assert_eq!(response.parent_task.id, parent.id);
assert_eq!(response.parent_task.name, "Parent task");
let session_id = crate::workspace::resolve_session_id(None);
let current: Option<i64> = sqlx::query_scalar::<_, Option<i64>>(
"SELECT current_task_id FROM sessions WHERE session_id = ?",
)
.bind(&session_id)
.fetch_optional(ctx.pool())
.await
.unwrap()
.flatten();
assert_eq!(current, Some(response.subtask.id));
let retrieved = manager.get_task(response.subtask.id).await.unwrap();
assert_eq!(retrieved.status, "doing");
}
#[tokio::test]
async fn test_spawn_subtask_no_current_task() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let result = manager.spawn_subtask("Child", None).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_pick_next_tasks_basic() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
for i in 1..=10 {
manager
.add_task(format!("Task {}", i), None, None, None, None, None)
.await
.unwrap();
}
let picked = manager.pick_next_tasks(5, 5).await.unwrap();
assert_eq!(picked.len(), 5);
for task in &picked {
assert_eq!(task.status, "doing");
assert!(task.first_doing_at.is_some());
}
let doing_count: i64 =
sqlx::query_scalar::<_, i64>(crate::sql_constants::COUNT_TASKS_DOING)
.fetch_one(ctx.pool())
.await
.unwrap();
assert_eq!(doing_count, 5);
}
#[tokio::test]
async fn test_pick_next_tasks_with_existing_doing() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
for i in 1..=10 {
manager
.add_task(format!("Task {}", i), None, None, None, None, None)
.await
.unwrap();
}
let result = manager
.find_tasks(Some("todo".to_string()), None, None, None, None)
.await
.unwrap();
manager.start_task(result.tasks[0].id, false).await.unwrap();
manager.start_task(result.tasks[1].id, false).await.unwrap();
let picked = manager.pick_next_tasks(10, 5).await.unwrap();
assert_eq!(picked.len(), 3);
let doing_count: i64 =
sqlx::query_scalar::<_, i64>(crate::sql_constants::COUNT_TASKS_DOING)
.fetch_one(ctx.pool())
.await
.unwrap();
assert_eq!(doing_count, 5);
}
#[tokio::test]
async fn test_pick_next_tasks_at_capacity() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
for i in 1..=10 {
manager
.add_task(format!("Task {}", i), None, None, None, None, None)
.await
.unwrap();
}
let first_batch = manager.pick_next_tasks(5, 5).await.unwrap();
assert_eq!(first_batch.len(), 5);
let second_batch = manager.pick_next_tasks(5, 5).await.unwrap();
assert_eq!(second_batch.len(), 0);
}
#[tokio::test]
async fn test_pick_next_tasks_priority_ordering() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let low = manager
.add_task("Low priority".to_string(), None, None, None, None, None)
.await
.unwrap();
manager
.update_task(
low.id,
TaskUpdate {
priority: Some(1),
..Default::default()
},
)
.await
.unwrap();
let high = manager
.add_task("High priority".to_string(), None, None, None, None, None)
.await
.unwrap();
manager
.update_task(
high.id,
TaskUpdate {
priority: Some(10),
..Default::default()
},
)
.await
.unwrap();
let medium = manager
.add_task("Medium priority".to_string(), None, None, None, None, None)
.await
.unwrap();
manager
.update_task(
medium.id,
TaskUpdate {
priority: Some(5),
..Default::default()
},
)
.await
.unwrap();
let picked = manager.pick_next_tasks(3, 5).await.unwrap();
assert_eq!(picked.len(), 3);
assert_eq!(picked[0].priority, Some(1)); assert_eq!(picked[1].priority, Some(5)); assert_eq!(picked[2].priority, Some(10)); }
#[tokio::test]
async fn test_pick_next_tasks_complexity_ordering() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let complex = manager
.add_task("Complex".to_string(), None, None, None, None, None)
.await
.unwrap();
manager
.update_task(
complex.id,
TaskUpdate {
complexity: Some(9),
priority: Some(5),
..Default::default()
},
)
.await
.unwrap();
let simple = manager
.add_task("Simple".to_string(), None, None, None, None, None)
.await
.unwrap();
manager
.update_task(
simple.id,
TaskUpdate {
complexity: Some(1),
priority: Some(5),
..Default::default()
},
)
.await
.unwrap();
let medium = manager
.add_task("Medium".to_string(), None, None, None, None, None)
.await
.unwrap();
manager
.update_task(
medium.id,
TaskUpdate {
complexity: Some(5),
priority: Some(5),
..Default::default()
},
)
.await
.unwrap();
let picked = manager.pick_next_tasks(3, 5).await.unwrap();
assert_eq!(picked.len(), 3);
assert_eq!(picked[0].complexity, Some(1)); assert_eq!(picked[1].complexity, Some(5)); assert_eq!(picked[2].complexity, Some(9)); }
#[tokio::test]
async fn test_done_task_sibling_tasks_remain() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let parent = manager
.add_task("Parent Task".to_string(), None, None, None, None, None)
.await
.unwrap();
let child1 = manager
.add_task(
"Child 1".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
let child2 = manager
.add_task(
"Child 2".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
let _child3 = manager
.add_task(
"Child 3".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
manager.start_task(child1.id, false).await.unwrap();
let response = manager.done_task(false).await.unwrap();
match response.next_step_suggestion {
NextStepSuggestion::SiblingTasksRemain {
parent_task_id,
remaining_siblings_count,
..
} => {
assert_eq!(parent_task_id, parent.id);
assert_eq!(remaining_siblings_count, 2); },
_ => panic!("Expected SiblingTasksRemain suggestion"),
}
manager.start_task(child2.id, false).await.unwrap();
let response2 = manager.done_task(false).await.unwrap();
match response2.next_step_suggestion {
NextStepSuggestion::SiblingTasksRemain {
remaining_siblings_count,
..
} => {
assert_eq!(remaining_siblings_count, 1); },
_ => panic!("Expected SiblingTasksRemain suggestion"),
}
}
#[tokio::test]
async fn test_done_task_top_level_with_children() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let parent = manager
.add_task("Epic Task".to_string(), None, None, None, None, None)
.await
.unwrap();
let child = manager
.add_task(
"Sub Task".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
manager.start_task(child.id, false).await.unwrap();
manager.done_task(false).await.unwrap();
manager.start_task(parent.id, false).await.unwrap();
let response = manager.done_task(false).await.unwrap();
match response.next_step_suggestion {
NextStepSuggestion::TopLevelTaskCompleted {
completed_task_id,
completed_task_name,
..
} => {
assert_eq!(completed_task_id, parent.id);
assert_eq!(completed_task_name, "Epic Task");
},
_ => panic!("Expected TopLevelTaskCompleted suggestion"),
}
}
#[tokio::test]
async fn test_done_task_no_parent_context() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let task1 = manager
.add_task(
"Standalone Task 1".to_string(),
None,
None,
None,
None,
None,
)
.await
.unwrap();
let _task2 = manager
.add_task(
"Standalone Task 2".to_string(),
None,
None,
None,
None,
None,
)
.await
.unwrap();
manager.start_task(task1.id, false).await.unwrap();
let response = manager.done_task(false).await.unwrap();
match response.next_step_suggestion {
NextStepSuggestion::NoParentContext {
completed_task_id,
completed_task_name,
..
} => {
assert_eq!(completed_task_id, task1.id);
assert_eq!(completed_task_name, "Standalone Task 1");
},
_ => panic!("Expected NoParentContext suggestion"),
}
}
#[tokio::test]
async fn test_done_task_by_id_non_focused_task_preserves_focus() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let task_a = manager
.add_task("Task A".to_string(), None, None, None, None, None)
.await
.unwrap();
let task_b = manager
.add_task("Task B".to_string(), None, None, None, None, None)
.await
.unwrap();
manager.start_task(task_a.id, false).await.unwrap();
let response = manager.done_task_by_id(task_b.id, false).await.unwrap();
assert_eq!(response.completed_task.status, "done");
assert_eq!(response.completed_task.id, task_b.id);
assert_eq!(response.workspace_status.current_task_id, Some(task_a.id));
let session_id = crate::workspace::resolve_session_id(None);
let current: Option<i64> = sqlx::query_scalar::<_, Option<i64>>(
"SELECT current_task_id FROM sessions WHERE session_id = ?",
)
.bind(&session_id)
.fetch_optional(ctx.pool())
.await
.unwrap()
.flatten();
assert_eq!(current, Some(task_a.id));
}
#[tokio::test]
async fn test_done_task_by_id_focused_task_clears_focus() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let task = manager
.add_task("Focused task".to_string(), None, None, None, None, None)
.await
.unwrap();
manager.start_task(task.id, false).await.unwrap();
let response = manager.done_task_by_id(task.id, false).await.unwrap();
assert_eq!(response.completed_task.status, "done");
assert_eq!(response.workspace_status.current_task_id, None);
let session_id = crate::workspace::resolve_session_id(None);
let current: Option<i64> = sqlx::query_scalar::<_, Option<i64>>(
"SELECT current_task_id FROM sessions WHERE session_id = ?",
)
.bind(&session_id)
.fetch_optional(ctx.pool())
.await
.unwrap()
.flatten();
assert!(current.is_none());
}
#[tokio::test]
async fn test_done_task_by_id_human_task_rejected_for_ai_caller() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let task = manager
.add_task(
"Human task".to_string(),
None,
None,
Some("human".to_string()),
None,
None,
)
.await
.unwrap();
manager
.update_task(
task.id,
TaskUpdate {
status: Some("doing"),
..Default::default()
},
)
.await
.unwrap();
let result = manager.done_task_by_id(task.id, true).await;
assert!(matches!(
result,
Err(IntentError::HumanTaskCannotBeCompletedByAI { .. })
));
let response = manager.done_task_by_id(task.id, false).await.unwrap();
assert_eq!(response.completed_task.status, "done");
}
#[tokio::test]
async fn test_done_task_by_id_with_uncompleted_children() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let parent = manager
.add_task("Parent".to_string(), None, None, None, None, None)
.await
.unwrap();
manager
.add_task(
"Incomplete child".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
let result = manager.done_task_by_id(parent.id, false).await;
assert!(matches!(result, Err(IntentError::UncompletedChildren)));
}
#[tokio::test]
async fn test_done_task_by_id_nonexistent_task() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let result = manager.done_task_by_id(99999, false).await;
assert!(matches!(result, Err(IntentError::TaskNotFound(99999))));
}
#[tokio::test]
async fn test_done_task_synthesis_graceful_when_llm_unconfigured() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let event_mgr = EventManager::new(ctx.pool());
let task = manager
.add_task(
"Test Task".to_string(),
Some("Original spec".to_string()),
None,
Some("ai".to_string()),
None,
None,
)
.await
.unwrap();
event_mgr
.add_event(task.id, "decision".to_string(), "Test decision".to_string())
.await
.unwrap();
manager.start_task(task.id, false).await.unwrap();
let result = manager.done_task_by_id(task.id, false).await;
assert!(result.is_ok(), "Task completion should succeed without LLM");
let completed_task = manager.get_task(task.id).await.unwrap();
assert_eq!(completed_task.status, "done");
assert_eq!(completed_task.spec, Some("Original spec".to_string()));
}
#[tokio::test]
async fn test_done_task_synthesis_respects_owner_field() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let ai_task = manager
.add_task(
"AI Task".to_string(),
Some("AI spec".to_string()),
None,
Some("ai".to_string()),
None,
None,
)
.await
.unwrap();
assert_eq!(ai_task.owner, "ai");
let human_task = manager
.add_task(
"Human Task".to_string(),
Some("Human spec".to_string()),
None,
Some("human".to_string()),
None,
None,
)
.await
.unwrap();
assert_eq!(human_task.owner, "human");
manager.start_task(ai_task.id, false).await.unwrap();
let result = manager.done_task_by_id(ai_task.id, false).await;
assert!(result.is_ok());
manager.start_task(human_task.id, false).await.unwrap();
let result = manager.done_task_by_id(human_task.id, false).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_try_synthesize_task_description_basic() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let task = manager
.add_task(
"Synthesis Test".to_string(),
Some("Original".to_string()),
None,
None,
None,
None,
)
.await
.unwrap();
let result = manager
.try_synthesize_task_description(task.id, &task.name)
.await;
assert!(result.is_ok(), "Should not error when LLM unconfigured");
assert_eq!(
result.unwrap(),
None,
"Should return None when LLM unconfigured"
);
}
#[tokio::test]
async fn test_pick_next_focused_subtask() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let parent = manager
.add_task("Parent task".to_string(), None, None, None, None, None)
.await
.unwrap();
manager.start_task(parent.id, false).await.unwrap();
let subtask1 = manager
.add_task(
"Subtask 1".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
let subtask2 = manager
.add_task(
"Subtask 2".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
manager
.update_task(
subtask1.id,
TaskUpdate {
priority: Some(2),
..Default::default()
},
)
.await
.unwrap();
manager
.update_task(
subtask2.id,
TaskUpdate {
priority: Some(1),
..Default::default()
},
)
.await
.unwrap();
let response = manager.pick_next().await.unwrap();
assert_eq!(response.suggestion_type, "FOCUSED_SUB_TASK");
assert!(response.task.is_some());
assert_eq!(response.task.as_ref().unwrap().id, subtask2.id);
assert_eq!(response.task.as_ref().unwrap().name, "Subtask 2");
}
#[tokio::test]
async fn test_pick_next_top_level_task() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let task1 = manager
.add_task("Task 1".to_string(), None, None, None, None, None)
.await
.unwrap();
let task2 = manager
.add_task("Task 2".to_string(), None, None, None, None, None)
.await
.unwrap();
manager
.update_task(
task1.id,
TaskUpdate {
priority: Some(5),
..Default::default()
},
)
.await
.unwrap();
manager
.update_task(
task2.id,
TaskUpdate {
priority: Some(3),
..Default::default()
},
)
.await
.unwrap();
let response = manager.pick_next().await.unwrap();
assert_eq!(response.suggestion_type, "TOP_LEVEL_TASK");
assert!(response.task.is_some());
assert_eq!(response.task.as_ref().unwrap().id, task2.id);
assert_eq!(response.task.as_ref().unwrap().name, "Task 2");
}
#[tokio::test]
async fn test_pick_next_no_tasks() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let response = manager.pick_next().await.unwrap();
assert_eq!(response.suggestion_type, "NONE");
assert_eq!(response.reason_code.as_deref(), Some("NO_TASKS_IN_PROJECT"));
assert!(response.message.is_some());
}
#[tokio::test]
async fn test_pick_next_all_completed() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let task = manager
.add_task("Task 1".to_string(), None, None, None, None, None)
.await
.unwrap();
manager.start_task(task.id, false).await.unwrap();
manager.done_task(false).await.unwrap();
let response = manager.pick_next().await.unwrap();
assert_eq!(response.suggestion_type, "NONE");
assert_eq!(response.reason_code.as_deref(), Some("ALL_TASKS_COMPLETED"));
assert!(response.message.is_some());
}
#[tokio::test]
async fn test_pick_next_no_available_todos() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let parent = manager
.add_task("Parent task".to_string(), None, None, None, None, None)
.await
.unwrap();
manager.start_task(parent.id, false).await.unwrap();
let subtask = manager
.add_task(
"Subtask".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
sqlx::query("UPDATE tasks SET status = 'doing' WHERE id = ?")
.bind(subtask.id)
.execute(ctx.pool())
.await
.unwrap();
let session_id = crate::workspace::resolve_session_id(None);
sqlx::query(
r#"
INSERT INTO sessions (session_id, current_task_id, created_at, last_active_at)
VALUES (?, ?, datetime('now'), datetime('now'))
ON CONFLICT(session_id) DO UPDATE SET
current_task_id = excluded.current_task_id,
last_active_at = datetime('now')
"#,
)
.bind(&session_id)
.bind(subtask.id)
.execute(ctx.pool())
.await
.unwrap();
sqlx::query("UPDATE tasks SET status = 'doing' WHERE id = ?")
.bind(parent.id)
.execute(ctx.pool())
.await
.unwrap();
let response = manager.pick_next().await.unwrap();
assert_eq!(response.suggestion_type, "TOP_LEVEL_TASK");
assert_eq!(response.task.as_ref().unwrap().id, parent.id);
assert_eq!(response.task.as_ref().unwrap().status, "doing");
}
#[tokio::test]
async fn test_pick_next_priority_ordering() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let parent = manager
.add_task("Parent".to_string(), None, None, None, None, None)
.await
.unwrap();
manager.start_task(parent.id, false).await.unwrap();
let sub1 = manager
.add_task(
"Priority 10".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
manager
.update_task(
sub1.id,
TaskUpdate {
priority: Some(10),
..Default::default()
},
)
.await
.unwrap();
let sub2 = manager
.add_task(
"Priority 1".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
manager
.update_task(
sub2.id,
TaskUpdate {
priority: Some(1),
..Default::default()
},
)
.await
.unwrap();
let sub3 = manager
.add_task(
"Priority 5".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
manager
.update_task(
sub3.id,
TaskUpdate {
priority: Some(5),
..Default::default()
},
)
.await
.unwrap();
let response = manager.pick_next().await.unwrap();
assert_eq!(response.suggestion_type, "FOCUSED_SUB_TASK");
assert_eq!(response.task.as_ref().unwrap().id, sub2.id);
assert_eq!(response.task.as_ref().unwrap().name, "Priority 1");
}
#[tokio::test]
async fn test_pick_next_falls_back_to_top_level_when_no_subtasks() {
let ctx = TestContext::new().await;
let manager = TaskManager::new(ctx.pool());
let parent = manager
.add_task("Parent".to_string(), None, None, None, None, None)
.await
.unwrap();
manager.start_task(parent.id, false).await.unwrap();
let top_level = manager
.add_task("Top level task".to_string(), None, None, None, None, None)
.await
.unwrap();
let response = manager.pick_next().await.unwrap();
assert_eq!(response.suggestion_type, "TOP_LEVEL_TASK");
assert_eq!(response.task.as_ref().unwrap().id, top_level.id);
}
#[tokio::test]
async fn test_get_task_with_events() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let event_mgr = EventManager::new(ctx.pool());
let task = task_mgr
.add_task("Test".to_string(), None, None, None, None, None)
.await
.unwrap();
event_mgr
.add_event(task.id, "progress".to_string(), "Event 1".to_string())
.await
.unwrap();
event_mgr
.add_event(task.id, "decision".to_string(), "Event 2".to_string())
.await
.unwrap();
let result = task_mgr.get_task_with_events(task.id).await.unwrap();
assert_eq!(result.task.id, task.id);
assert!(result.events_summary.is_some());
let summary = result.events_summary.unwrap();
assert_eq!(summary.total_count, 2);
assert_eq!(summary.recent_events.len(), 2);
assert_eq!(summary.recent_events[0].log_type, "decision"); assert_eq!(summary.recent_events[1].log_type, "progress");
}
#[tokio::test]
async fn test_get_task_with_events_nonexistent() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let result = task_mgr.get_task_with_events(999).await;
assert!(matches!(result, Err(IntentError::TaskNotFound(999))));
}
#[tokio::test]
async fn test_get_task_with_many_events() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let event_mgr = EventManager::new(ctx.pool());
let task = task_mgr
.add_task("Test".to_string(), None, None, None, None, None)
.await
.unwrap();
for i in 0..20 {
event_mgr
.add_event(task.id, "test".to_string(), format!("Event {}", i))
.await
.unwrap();
}
let result = task_mgr.get_task_with_events(task.id).await.unwrap();
let summary = result.events_summary.unwrap();
assert_eq!(summary.total_count, 20);
assert_eq!(summary.recent_events.len(), 10); }
#[tokio::test]
async fn test_get_task_with_no_events() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let task = task_mgr
.add_task("Test".to_string(), None, None, None, None, None)
.await
.unwrap();
let result = task_mgr.get_task_with_events(task.id).await.unwrap();
let summary = result.events_summary.unwrap();
assert_eq!(summary.total_count, 0);
assert_eq!(summary.recent_events.len(), 0);
}
#[tokio::test]
async fn test_pick_next_tasks_zero_capacity() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
task_mgr
.add_task("Task 1".to_string(), None, None, None, None, None)
.await
.unwrap();
let results = task_mgr.pick_next_tasks(10, 0).await.unwrap();
assert_eq!(results.len(), 0);
}
#[tokio::test]
async fn test_pick_next_tasks_capacity_exceeds_available() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
task_mgr
.add_task("Task 1".to_string(), None, None, None, None, None)
.await
.unwrap();
task_mgr
.add_task("Task 2".to_string(), None, None, None, None, None)
.await
.unwrap();
let results = task_mgr.pick_next_tasks(10, 100).await.unwrap();
assert_eq!(results.len(), 2); }
#[tokio::test]
async fn test_get_task_context_root_task_no_relations() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let task = task_mgr
.add_task("Root task".to_string(), None, None, None, None, None)
.await
.unwrap();
let context = task_mgr.get_task_context(task.id).await.unwrap();
assert_eq!(context.task.id, task.id);
assert_eq!(context.task.name, "Root task");
assert_eq!(context.ancestors.len(), 0);
assert_eq!(context.siblings.len(), 0);
assert_eq!(context.children.len(), 0);
}
#[tokio::test]
async fn test_get_task_context_with_siblings() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let task1 = task_mgr
.add_task("Task 1".to_string(), None, None, None, None, None)
.await
.unwrap();
let task2 = task_mgr
.add_task("Task 2".to_string(), None, None, None, None, None)
.await
.unwrap();
let task3 = task_mgr
.add_task("Task 3".to_string(), None, None, None, None, None)
.await
.unwrap();
let context = task_mgr.get_task_context(task2.id).await.unwrap();
assert_eq!(context.task.id, task2.id);
assert_eq!(context.ancestors.len(), 0);
assert_eq!(context.siblings.len(), 2);
let sibling_ids: Vec<i64> = context.siblings.iter().map(|t| t.id).collect();
assert!(sibling_ids.contains(&task1.id));
assert!(sibling_ids.contains(&task3.id));
assert!(!sibling_ids.contains(&task2.id));
assert_eq!(context.children.len(), 0);
}
#[tokio::test]
async fn test_get_task_context_with_parent() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let parent = task_mgr
.add_task("Parent task".to_string(), None, None, None, None, None)
.await
.unwrap();
let child = task_mgr
.add_task(
"Child task".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
let context = task_mgr.get_task_context(child.id).await.unwrap();
assert_eq!(context.task.id, child.id);
assert_eq!(context.task.parent_id, Some(parent.id));
assert_eq!(context.ancestors.len(), 1);
assert_eq!(context.ancestors[0].id, parent.id);
assert_eq!(context.ancestors[0].name, "Parent task");
assert_eq!(context.siblings.len(), 0);
assert_eq!(context.children.len(), 0);
}
#[tokio::test]
async fn test_get_task_context_with_children() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let parent = task_mgr
.add_task("Parent task".to_string(), None, None, None, None, None)
.await
.unwrap();
let child1 = task_mgr
.add_task(
"Child 1".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
let child2 = task_mgr
.add_task(
"Child 2".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
let child3 = task_mgr
.add_task(
"Child 3".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
let context = task_mgr.get_task_context(parent.id).await.unwrap();
assert_eq!(context.task.id, parent.id);
assert_eq!(context.ancestors.len(), 0);
assert_eq!(context.siblings.len(), 0);
assert_eq!(context.children.len(), 3);
let child_ids: Vec<i64> = context.children.iter().map(|t| t.id).collect();
assert!(child_ids.contains(&child1.id));
assert!(child_ids.contains(&child2.id));
assert!(child_ids.contains(&child3.id));
}
#[tokio::test]
async fn test_get_task_context_multi_level_hierarchy() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let grandparent = task_mgr
.add_task("Grandparent".to_string(), None, None, None, None, None)
.await
.unwrap();
let parent = task_mgr
.add_task(
"Parent".to_string(),
None,
Some(grandparent.id),
None,
None,
None,
)
.await
.unwrap();
let child = task_mgr
.add_task("Child".to_string(), None, Some(parent.id), None, None, None)
.await
.unwrap();
let context = task_mgr.get_task_context(child.id).await.unwrap();
assert_eq!(context.task.id, child.id);
assert_eq!(context.ancestors.len(), 2);
assert_eq!(context.ancestors[0].id, parent.id);
assert_eq!(context.ancestors[0].name, "Parent");
assert_eq!(context.ancestors[1].id, grandparent.id);
assert_eq!(context.ancestors[1].name, "Grandparent");
assert_eq!(context.siblings.len(), 0);
assert_eq!(context.children.len(), 0);
}
#[tokio::test]
async fn test_get_task_context_complex_family_tree() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let root = task_mgr
.add_task("Root".to_string(), None, None, None, None, None)
.await
.unwrap();
let child1 = task_mgr
.add_task("Child1".to_string(), None, Some(root.id), None, None, None)
.await
.unwrap();
let child2 = task_mgr
.add_task("Child2".to_string(), None, Some(root.id), None, None, None)
.await
.unwrap();
let grandchild1 = task_mgr
.add_task(
"Grandchild1".to_string(),
None,
Some(child1.id),
None,
None,
None,
)
.await
.unwrap();
let grandchild2 = task_mgr
.add_task(
"Grandchild2".to_string(),
None,
Some(child1.id),
None,
None,
None,
)
.await
.unwrap();
let context = task_mgr.get_task_context(grandchild2.id).await.unwrap();
assert_eq!(context.task.id, grandchild2.id);
assert_eq!(context.ancestors.len(), 2);
assert_eq!(context.ancestors[0].id, child1.id);
assert_eq!(context.ancestors[1].id, root.id);
assert_eq!(context.siblings.len(), 1);
assert_eq!(context.siblings[0].id, grandchild1.id);
assert_eq!(context.children.len(), 0);
let context_child1 = task_mgr.get_task_context(child1.id).await.unwrap();
assert_eq!(context_child1.ancestors.len(), 1);
assert_eq!(context_child1.ancestors[0].id, root.id);
assert_eq!(context_child1.siblings.len(), 1);
assert_eq!(context_child1.siblings[0].id, child2.id);
assert_eq!(context_child1.children.len(), 2);
}
#[tokio::test]
async fn test_get_task_context_respects_priority_ordering() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let parent = task_mgr
.add_task("Parent".to_string(), None, None, None, None, None)
.await
.unwrap();
let child_low = task_mgr
.add_task(
"Low priority".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
let _ = task_mgr
.update_task(
child_low.id,
TaskUpdate {
priority: Some(10),
..Default::default()
},
)
.await
.unwrap();
let child_high = task_mgr
.add_task(
"High priority".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
let _ = task_mgr
.update_task(
child_high.id,
TaskUpdate {
priority: Some(1),
..Default::default()
},
)
.await
.unwrap();
let child_medium = task_mgr
.add_task(
"Medium priority".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
let _ = task_mgr
.update_task(
child_medium.id,
TaskUpdate {
priority: Some(5),
..Default::default()
},
)
.await
.unwrap();
let context = task_mgr.get_task_context(parent.id).await.unwrap();
assert_eq!(context.children.len(), 3);
assert_eq!(context.children[0].priority, Some(1));
assert_eq!(context.children[1].priority, Some(5));
assert_eq!(context.children[2].priority, Some(10));
}
#[tokio::test]
async fn test_get_task_context_nonexistent_task() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let result = task_mgr.get_task_context(99999).await;
assert!(result.is_err());
assert!(matches!(result, Err(IntentError::TaskNotFound(99999))));
}
#[tokio::test]
async fn test_get_task_context_handles_null_priority() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let task1 = task_mgr
.add_task("Task 1".to_string(), None, None, None, None, None)
.await
.unwrap();
let _ = task_mgr
.update_task(
task1.id,
TaskUpdate {
priority: Some(1),
..Default::default()
},
)
.await
.unwrap();
let task2 = task_mgr
.add_task("Task 2".to_string(), None, None, None, None, None)
.await
.unwrap();
let task3 = task_mgr
.add_task("Task 3".to_string(), None, None, None, None, None)
.await
.unwrap();
let _ = task_mgr
.update_task(
task3.id,
TaskUpdate {
priority: Some(5),
..Default::default()
},
)
.await
.unwrap();
let context = task_mgr.get_task_context(task2.id).await.unwrap();
assert_eq!(context.siblings.len(), 2);
assert_eq!(context.siblings[0].id, task1.id);
assert_eq!(context.siblings[0].priority, Some(1));
assert_eq!(context.siblings[1].id, task3.id);
assert_eq!(context.siblings[1].priority, Some(5));
}
#[tokio::test]
async fn test_pick_next_tasks_priority_order() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let critical = task_mgr
.add_task("Critical Task".to_string(), None, None, None, None, None)
.await
.unwrap();
task_mgr
.update_task(
critical.id,
TaskUpdate {
priority: Some(1),
..Default::default()
},
)
.await
.unwrap();
let low = task_mgr
.add_task("Low Task".to_string(), None, None, None, None, None)
.await
.unwrap();
task_mgr
.update_task(
low.id,
TaskUpdate {
priority: Some(4),
..Default::default()
},
)
.await
.unwrap();
let high = task_mgr
.add_task("High Task".to_string(), None, None, None, None, None)
.await
.unwrap();
task_mgr
.update_task(
high.id,
TaskUpdate {
priority: Some(2),
..Default::default()
},
)
.await
.unwrap();
let medium = task_mgr
.add_task("Medium Task".to_string(), None, None, None, None, None)
.await
.unwrap();
task_mgr
.update_task(
medium.id,
TaskUpdate {
priority: Some(3),
..Default::default()
},
)
.await
.unwrap();
let tasks = task_mgr.pick_next_tasks(10, 10).await.unwrap();
assert_eq!(tasks.len(), 4);
assert_eq!(tasks[0].id, critical.id); assert_eq!(tasks[1].id, high.id); assert_eq!(tasks[2].id, medium.id); assert_eq!(tasks[3].id, low.id); }
#[tokio::test]
async fn test_pick_next_prefers_doing_over_todo() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let workspace_mgr = WorkspaceManager::new(ctx.pool());
let parent = task_mgr
.add_task("Parent".to_string(), None, None, None, None, None)
.await
.unwrap();
let parent_started = task_mgr.start_task(parent.id, false).await.unwrap();
workspace_mgr
.set_current_task(parent_started.task.id, None)
.await
.unwrap();
let doing_subtask = task_mgr
.add_task(
"Doing Subtask".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
task_mgr.start_task(doing_subtask.id, false).await.unwrap();
workspace_mgr
.set_current_task(parent.id, None)
.await
.unwrap();
let _todo_subtask = task_mgr
.add_task(
"Todo Subtask".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
let result = task_mgr.pick_next().await.unwrap();
if let Some(task) = result.task {
assert_eq!(
task.id, doing_subtask.id,
"Should recommend doing subtask over todo subtask"
);
assert_eq!(task.status, "doing");
} else {
panic!("Expected a task recommendation");
}
}
#[tokio::test]
async fn test_multiple_doing_tasks_allowed() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let workspace_mgr = WorkspaceManager::new(ctx.pool());
let task_a = task_mgr
.add_task("Task A".to_string(), None, None, None, None, None)
.await
.unwrap();
let task_a_started = task_mgr.start_task(task_a.id, false).await.unwrap();
assert_eq!(task_a_started.task.status, "doing");
let current = workspace_mgr.get_current_task(None).await.unwrap();
assert_eq!(current.current_task_id, Some(task_a.id));
let task_b = task_mgr
.add_task("Task B".to_string(), None, None, None, None, None)
.await
.unwrap();
let task_b_started = task_mgr.start_task(task_b.id, false).await.unwrap();
assert_eq!(task_b_started.task.status, "doing");
let current = workspace_mgr.get_current_task(None).await.unwrap();
assert_eq!(current.current_task_id, Some(task_b.id));
let task_a_after = task_mgr.get_task(task_a.id).await.unwrap();
assert_eq!(
task_a_after.status, "doing",
"Task A should remain doing even though it is not current"
);
let doing_tasks: Vec<Task> = sqlx::query_as(
r#"SELECT id, parent_id, name, spec, status, complexity, priority, first_todo_at, first_doing_at, first_done_at, active_form, owner, metadata
FROM tasks WHERE status = 'doing' AND deleted_at IS NULL ORDER BY id"#
)
.fetch_all(ctx.pool())
.await
.unwrap();
assert_eq!(doing_tasks.len(), 2, "Should have 2 doing tasks");
assert_eq!(doing_tasks[0].id, task_a.id);
assert_eq!(doing_tasks[1].id, task_b.id);
}
#[tokio::test]
async fn test_find_tasks_pagination() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
for i in 0..15 {
task_mgr
.add_task(format!("Task {}", i), None, None, None, None, None)
.await
.unwrap();
}
let page1 = task_mgr
.find_tasks(None, None, None, Some(10), Some(0))
.await
.unwrap();
assert_eq!(page1.tasks.len(), 10);
assert_eq!(page1.total_count, 15);
assert!(page1.has_more);
assert_eq!(page1.offset, 0);
let page2 = task_mgr
.find_tasks(None, None, None, Some(10), Some(10))
.await
.unwrap();
assert_eq!(page2.tasks.len(), 5);
assert_eq!(page2.total_count, 15);
assert!(!page2.has_more);
assert_eq!(page2.offset, 10);
}
}