Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 7 additions & 7 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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. <[email protected]>"]
Expand Down Expand Up @@ -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"
Expand Down
151 changes: 112 additions & 39 deletions crates/ptask-core/src/tasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = tx
.query_row("SELECT pt_id FROM tasks WHERE id=?1", [task_uuid], |r| {
Expand All @@ -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(())
}

Expand All @@ -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<String> = tx
.query_row("SELECT status FROM tasks WHERE id=?1", [task_uuid], |r| {
r.get(0)
Expand All @@ -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(())
}

Expand Down Expand Up @@ -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<UndoOutcome> {
// 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::<std::result::Result<_, _>>()?
};

// 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<String> =
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<String> = 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(
Expand Down Expand Up @@ -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 —
Expand Down
2 changes: 1 addition & 1 deletion docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ not-yet-retired consumers).
| Verb | Use |
|---|---|
| `pt log <query> [-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 <client_id> [--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 <client_id>` | revoke all active tokens for a client |
Expand Down
4 changes: 2 additions & 2 deletions docs/gen/pt.1
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Loading