diff --git a/crates/storage/src/database.rs b/crates/storage/src/database.rs index dddc9bb..eaae161 100644 --- a/crates/storage/src/database.rs +++ b/crates/storage/src/database.rs @@ -441,11 +441,42 @@ pub async fn complete_task(pool: &SqlitePool, id: &str) -> Result<()> { } pub async fn uncomplete_task(pool: &SqlitePool, id: &str) -> Result<()> { + let mut tx = pool + .begin() + .await + .map_err(|e| KonError::StorageError(format!("Begin transaction failed: {e}")))?; + sqlx::query("UPDATE tasks SET done = 0, done_at = NULL WHERE id = ?") .bind(id) - .execute(pool) + .execute(&mut *tx) .await .map_err(|e| KonError::StorageError(format!("Uncomplete task failed: {e}")))?; + + // Mirror the auto-complete invariant from + // `complete_subtask_and_check_parent`: a parent task is done iff + // every child is done. If the child we just reopened had a done + // parent, reopen the parent too so state stays consistent + // (2026-04-22 review MAJOR). No-op for top-level tasks. + let parent_id: Option = + sqlx::query_scalar("SELECT parent_task_id FROM tasks WHERE id = ?") + .bind(id) + .fetch_optional(&mut *tx) + .await + .map_err(|e| KonError::StorageError(format!("Get parent_task_id failed: {e}")))? + .flatten(); + + if let Some(pid) = parent_id { + sqlx::query("UPDATE tasks SET done = 0, done_at = NULL WHERE id = ? AND done = 1") + .bind(&pid) + .execute(&mut *tx) + .await + .map_err(|e| KonError::StorageError(format!("Reopen parent failed: {e}")))?; + } + + tx.commit() + .await + .map_err(|e| KonError::StorageError(format!("Commit transaction failed: {e}")))?; + Ok(()) } @@ -1005,6 +1036,57 @@ mod tests { ); } + #[tokio::test] + async fn uncomplete_subtask_reopens_auto_completed_parent() { + // Regression for the 2026-04-22 review MAJOR on + // asymmetric complete / uncomplete semantics: once all + // subtasks completed auto-completes the parent, reopening a + // child must also reopen the parent so "parent is done iff + // every child is done" holds in both directions. + let pool = test_pool().await; + insert_task(&pool, "p1", "Ship release", "inbox", None, None, None) + .await + .unwrap(); + insert_subtask(&pool, "s1", "Final test", "p1").await.unwrap(); + insert_subtask(&pool, "s2", "Tag release", "p1").await.unwrap(); + + complete_subtask_and_check_parent(&pool, "s1").await.unwrap(); + complete_subtask_and_check_parent(&pool, "s2").await.unwrap(); + let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap(); + assert!(parent.done); + + uncomplete_task(&pool, "s2").await.unwrap(); + let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap(); + assert!( + !parent.done, + "reopening any child must reopen the auto-completed parent" + ); + let s2 = get_task_by_id(&pool, "s2").await.unwrap().unwrap(); + assert!(!s2.done, "subtask itself must also be reopened"); + } + + #[tokio::test] + async fn uncomplete_top_level_task_does_not_touch_siblings() { + // Sanity: tasks without a parent must not trigger the parent- + // reopen branch (it should no-op cleanly). + let pool = test_pool().await; + insert_task(&pool, "a", "A", "inbox", None, None, None) + .await + .unwrap(); + insert_task(&pool, "b", "B", "inbox", None, None, None) + .await + .unwrap(); + complete_task(&pool, "a").await.unwrap(); + complete_task(&pool, "b").await.unwrap(); + + uncomplete_task(&pool, "a").await.unwrap(); + + let a = get_task_by_id(&pool, "a").await.unwrap().unwrap(); + let b = get_task_by_id(&pool, "b").await.unwrap().unwrap(); + assert!(!a.done); + assert!(b.done, "sibling must be untouched"); + } + #[tokio::test] async fn update_transcript_meta_happy_path() { // Task 2.5 — insert a transcript, update starred=true, read it back.