From e1256c5aa696623eb43348f2f4e4cf57eb3b3c3e Mon Sep 17 00:00:00 2001 From: HAL Date: Sat, 12 Sep 2026 06:07:56 +0100 Subject: [PATCH] v3.30.5: preserve later task work and advance undo history --- Cargo.lock | 14 +-- Cargo.toml | 14 +-- crates/ptask-core/src/tasks.rs | 151 ++++++++++++++++++++++++--------- docs/cli-reference.md | 2 +- docs/gen/pt.1 | 4 +- 5 files changed, 129 insertions(+), 56 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9d57cfda..fd6d2875 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2649,7 +2649,7 @@ dependencies = [ [[package]] name = "ptask-bot" -version = "3.30.4" +version = "3.30.5" dependencies = [ "anyhow", "jiff", @@ -2665,7 +2665,7 @@ dependencies = [ [[package]] name = "ptask-cli" -version = "3.30.4" +version = "3.30.5" dependencies = [ "anyhow", "axum", @@ -2693,7 +2693,7 @@ dependencies = [ [[package]] name = "ptask-core" -version = "3.30.4" +version = "3.30.5" dependencies = [ "hex", "interim", @@ -2714,7 +2714,7 @@ dependencies = [ [[package]] name = "ptask-distill" -version = "3.30.4" +version = "3.30.5" dependencies = [ "anyhow", "candle-core", @@ -2737,7 +2737,7 @@ dependencies = [ [[package]] name = "ptask-notify" -version = "3.30.4" +version = "3.30.5" dependencies = [ "lettre", "ptask-core", @@ -2750,7 +2750,7 @@ dependencies = [ [[package]] name = "ptask-server" -version = "3.30.4" +version = "3.30.5" dependencies = [ "anyhow", "axum", @@ -2777,7 +2777,7 @@ dependencies = [ [[package]] name = "ptask-tui" -version = "3.30.4" +version = "3.30.5" dependencies = [ "anyhow", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index 1e26d370..b0d40e5f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ resolver = "2" [workspace.package] -version = "3.30.4" +version = "3.30.5" edition = "2024" license-file = "LICENSE" authors = ["PureTensor, Inc. "] @@ -52,12 +52,12 @@ rmcp = { version = "3.3.0", features = ["server", "macros", "schemars", "transpo futures-util = { version = "0.3", default-features = false } # Internal crates -ptask-core = { path = "crates/ptask-core", version = "3.30.4" } -ptask-tui = { path = "crates/ptask-tui", version = "3.30.4" } -ptask-server = { path = "crates/ptask-server", version = "3.30.4" } -ptask-bot = { path = "crates/ptask-bot", version = "3.30.4" } -ptask-distill = { path = "crates/ptask-distill", version = "3.30.4" } -ptask-notify = { path = "crates/ptask-notify", version = "3.30.4" } +ptask-core = { path = "crates/ptask-core", version = "3.30.5" } +ptask-tui = { path = "crates/ptask-tui", version = "3.30.5" } +ptask-server = { path = "crates/ptask-server", version = "3.30.5" } +ptask-bot = { path = "crates/ptask-bot", version = "3.30.5" } +ptask-distill = { path = "crates/ptask-distill", version = "3.30.5" } +ptask-notify = { path = "crates/ptask-notify", version = "3.30.5" } # HMAC for outbound webhook signing (v0.3.5). sha2 = "0.10" diff --git a/crates/ptask-core/src/tasks.rs b/crates/ptask-core/src/tasks.rs index ac0fb84a..fc55db77 100644 --- a/crates/ptask-core/src/tasks.rs +++ b/crates/ptask-core/src/tasks.rs @@ -1012,6 +1012,12 @@ pub fn update_deadline( pub fn delete_task(db: &Db, task_uuid: &str, ctx: &EventCtx) -> Result<()> { let mut conn = db.get()?; let tx = conn.transaction()?; + delete_task_in_conn(&tx, task_uuid, ctx)?; + tx.commit()?; + Ok(()) +} + +fn delete_task_in_conn(tx: &rusqlite::Connection, task_uuid: &str, ctx: &EventCtx) -> Result<()> { // Capture the PT-N before the CASCADE wipes pt_extensions. let pt_id: Option = tx .query_row("SELECT pt_id FROM tasks WHERE id=?1", [task_uuid], |r| { @@ -1020,13 +1026,12 @@ pub fn delete_task(db: &Db, task_uuid: &str, ctx: &EventCtx) -> Result<()> { .optional()?; tx.execute("DELETE FROM tasks WHERE id=?1", [task_uuid])?; record_event_tx( - &tx, + tx, ctx, task_uuid, "task.deleted", &serde_json::json!({ "task_uuid": task_uuid, "pt_id": pt_id }), )?; - tx.commit()?; Ok(()) } @@ -1037,9 +1042,15 @@ pub fn delete_task(db: &Db, task_uuid: &str, ctx: &EventCtx) -> Result<()> { /// `status_change` interaction's details contain `'pending'`, which the /// neglect score reads as a reopen signal. pub fn reopen(db: &Db, task_uuid: &str, ctx: &EventCtx) -> Result<()> { - let now = iso_now(); let mut conn = db.get()?; let tx = conn.transaction()?; + reopen_in_conn(&tx, task_uuid, ctx)?; + tx.commit()?; + Ok(()) +} + +fn reopen_in_conn(tx: &rusqlite::Connection, task_uuid: &str, ctx: &EventCtx) -> Result<()> { + let now = iso_now(); let status: Option = tx .query_row("SELECT status FROM tasks WHERE id=?1", [task_uuid], |r| { r.get(0) @@ -1065,13 +1076,12 @@ pub fn reopen(db: &Db, task_uuid: &str, ctx: &EventCtx) -> Result<()> { ], )?; record_event_tx( - &tx, + tx, ctx, task_uuid, "task.updated", &serde_json::json!({ "task_uuid": task_uuid, "status": "pending" }), )?; - tx.commit()?; Ok(()) } @@ -1129,72 +1139,69 @@ pub struct UndoOutcome { /// their events don't carry the prior state yet. The reversal itself is a /// normal attributed mutation, so `pt log` shows both sides. pub fn undo_last(db: &Db, ctx: &EventCtx) -> Result { + // Keep the selected history and current task state stable until reversal. + // A concurrent claim/edit must not commit between validation and deletion. + let mut conn = db.get()?; + let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; let candidates: Vec<(i64, String, String, String)> = { - let conn = db.get()?; - let mut stmt = conn.prepare( + let mut stmt = tx.prepare( "SELECT id, task_uuid, event_type, payload FROM pt_event_log WHERE task_uuid IS NOT NULL - AND event_type IN ('task.completed', 'task.created', 'task.updated') ORDER BY id DESC LIMIT 50", )?; let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))?; rows.collect::>()? }; - // Tasks with a newer, non-undoable modification (priority/deadline/text/ - // start/snooze edit) seen earlier in this newest-first scan. Their - // `task.created` must NOT be deleted: the create is no longer the last - // thing that happened, so deleting would silently discard the later edit. - let mut modified_after_create: std::collections::HashSet = - std::collections::HashSet::new(); + // Only a task's newest event is eligible. This protects every later + // mutation, including newly introduced event types, and skips reversals + // already recorded by an earlier undo or a manual reopen. + let mut seen = std::collections::HashSet::new(); for (id, task_uuid, event_type, payload) in candidates { - let payload: serde_json::Value = serde_json::from_str(&payload).unwrap_or_default(); - let exists: bool = { - let conn = db.get()?; - conn.query_row("SELECT 1 FROM tasks WHERE id=?1", [&task_uuid], |_| Ok(())) - .optional()? - .is_some() - }; - if !exists { + if !seen.insert(task_uuid.clone()) { continue; } + let payload: serde_json::Value = serde_json::from_str(&payload).unwrap_or_default(); + let status: Option = tx + .query_row( + "SELECT status_v2 FROM tasks WHERE id=?1", + [&task_uuid], + |r| r.get(0), + ) + .optional()?; + let Some(status) = status else { + continue; + }; match event_type.as_str() { - "task.completed" => { - reopen(db, &task_uuid, ctx)?; + "task.completed" if status == "done" => { + reopen_in_conn(&tx, &task_uuid, ctx)?; + tx.commit()?; return Ok(UndoOutcome { reversed_event_id: id, description: format!("reopened {} (was completed)", task_uuid), }); } "task.updated" - if payload.get("status").and_then(|s| s.as_str()) == Some("dismissed") => + if status == "dismissed" + && payload.get("status").and_then(|s| s.as_str()) == Some("dismissed") => { - reopen(db, &task_uuid, ctx)?; + reopen_in_conn(&tx, &task_uuid, ctx)?; + tx.commit()?; return Ok(UndoOutcome { reversed_event_id: id, description: format!("reopened {} (was dismissed)", task_uuid), }); } "task.created" => { - if modified_after_create.contains(&task_uuid) { - // Created, then edited — "undo the create" would delete a - // task the operator has since worked on. Skip it. - continue; - } - delete_task(db, &task_uuid, ctx)?; + delete_task_in_conn(&tx, &task_uuid, ctx)?; + tx.commit()?; return Ok(UndoOutcome { reversed_event_id: id, description: format!("deleted {} (undid create)", task_uuid), }); } - _ => { - // A non-undoable edit (priority/deadline/text/start/snooze). - // Record it so this task's create is protected from deletion - // underneath a later modification. - modified_after_create.insert(task_uuid); - continue; - } + _ => continue, } } Err(crate::Error::Other( @@ -3169,6 +3176,72 @@ mod tests { assert_eq!(count, 0, "bare-create undo should delete the task"); } + #[test] + fn undo_preserves_claimed_promoted_and_advanced_tasks() { + for mutation in ["claim", "promote", "advance"] { + let (_dir, db) = fresh_db(); + let ctx = EventCtx::test(); + let mut new = NewTask::minimal("work must survive undo"); + let mut ext = Extensions::default(); + if mutation == "promote" { + ext.kind = Some("scout".into()); + ext.deliverable = Some("report".into()); + } else if mutation == "advance" { + new.deadline = Some("2099-01-01T09:00:00Z".into()); + ext.recurrence = Some(crate::recurrence::parse("every day").unwrap()); + } + let task = create_with_extensions(&db, new, ext, &ctx).unwrap(); + match mutation { + "claim" => claim(&db, &task.id, &ctx).unwrap(), + "promote" => promote(&db, &task.id, &ctx).unwrap(), + "advance" => { + mark_done(&db, &task, &ctx).unwrap(); + } + _ => unreachable!(), + } + let cursor = crate::event_log::current_cursor(&db).unwrap(); + assert!(undo_last(&db, &ctx).is_err(), "undo discarded {mutation}"); + assert!(resolve_for_lookup(&db, &task.id, true).is_ok()); + assert_eq!(crate::event_log::current_cursor(&db).unwrap(), cursor); + } + } + + #[test] + fn undo_moves_past_reopened_or_modified_completions() { + for later_change in ["undo", "reopen", "priority"] { + let (_dir, db) = fresh_db(); + let ctx = EventCtx::test(); + let a = create(&db, NewTask::minimal("first task"), &ctx).unwrap(); + let b = create(&db, NewTask::minimal("second task"), &ctx).unwrap(); + mark_done(&db, &a, &ctx).unwrap(); + mark_done(&db, &b, &ctx).unwrap(); + match later_change { + "undo" => { + undo_last(&db, &ctx).unwrap(); + } + "reopen" => reopen(&db, &b.id, &ctx).unwrap(), + "priority" => update_priority(&db, &b.id, 4, &ctx).unwrap(), + _ => unreachable!(), + } + let outcome = undo_last(&db, &ctx).unwrap(); + assert!( + outcome.description.contains(&a.id), + "{later_change}: {}", + outcome.description + ); + assert_eq!(resolve_for_lookup(&db, &a.id, true).unwrap().status, "todo"); + let b = resolve_for_lookup(&db, &b.id, true).unwrap(); + assert_eq!( + b.status, + if later_change == "priority" { + "done" + } else { + "todo" + } + ); + } + } + // ---- list ordering: severity is the primary key ------------------------- /// Create a task at `priority` and stamp its composite score directly — diff --git a/docs/cli-reference.md b/docs/cli-reference.md index fa08e74d..c15fd3b0 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -179,7 +179,7 @@ not-yet-retired consumers). | Verb | Use | |---|---| | `pt log [-n N]` | attributed event history for a task: when, who (actor), via which surface, what | -| `pt undo` | reverse the most recent undoable mutation (done/dismiss → reopen, create → delete); the reversal is itself an attributed event | +| `pt undo` | reverse the most recent eligible mutation within the last 50 task events (done/dismiss → reopen, create → delete); any later event on that task protects it, including claims, promotions and prior reversals. Selection and reversal are atomic; the reversal is itself attributed. | | `pt token create [--scope read\|capture\|write\|admin]` | mint a named scoped API token (plain value shown ONCE; only the sha256 is stored) | | `pt token list` | client, scope, active/revoked, created/last-used | | `pt token revoke ` | revoke all active tokens for a client | diff --git a/docs/gen/pt.1 b/docs/gen/pt.1 index 90f332af..600dc383 100644 --- a/docs/gen/pt.1 +++ b/docs/gen/pt.1 @@ -1,6 +1,6 @@ .ie \n(.g .ds Aq \(aq .el .ds Aq ' -.TH pt 1 "pt 3.30.4" +.TH pt 1 "pt 3.30.5" .SH NAME pt \- Sovereign task manager for PureTensor .SH SYNOPSIS @@ -166,4 +166,4 @@ Generate shell completions (bash/zsh/fish) to stdout pt\-help(1) Print this message or the help of the given subcommand(s) .SH VERSION -v3.30.4 +v3.30.5