feat(kanban): archive --rm to hard-delete archived tasks
Salvages #19964 by @Beandon13. Adds `hermes kanban archive --rm` to permanently remove already-archived tasks with cascading cleanup of links, comments, events, runs, and notify-subs. Safety guard: only archived tasks can be deleted; active/blocked/done must be archived first. Cherry-picked from #19964 onto current main (severe stale base, applied manually to preserve substance only).
This commit is contained in:
@@ -435,7 +435,15 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
|
|||||||
p_unblock.add_argument("task_ids", nargs="+")
|
p_unblock.add_argument("task_ids", nargs="+")
|
||||||
|
|
||||||
p_archive = sub.add_parser("archive", help="Archive one or more tasks")
|
p_archive = sub.add_parser("archive", help="Archive one or more tasks")
|
||||||
p_archive.add_argument("task_ids", nargs="+")
|
p_archive.add_argument("task_ids", nargs="*",
|
||||||
|
help="Task ids to archive (default mode)")
|
||||||
|
p_archive.add_argument(
|
||||||
|
"--rm",
|
||||||
|
dest="purge_ids",
|
||||||
|
nargs="+",
|
||||||
|
default=None,
|
||||||
|
help="Permanently delete already-archived task ids from the board",
|
||||||
|
)
|
||||||
|
|
||||||
# --- tail ---
|
# --- tail ---
|
||||||
p_tail = sub.add_parser("tail", help="Follow a task's event stream")
|
p_tail = sub.add_parser("tail", help="Follow a task's event stream")
|
||||||
@@ -1692,11 +1700,23 @@ def _cmd_unblock(args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
def _cmd_archive(args: argparse.Namespace) -> int:
|
def _cmd_archive(args: argparse.Namespace) -> int:
|
||||||
ids = list(args.task_ids or [])
|
ids = list(args.task_ids or [])
|
||||||
if not ids:
|
purge_ids = list(getattr(args, "purge_ids", None) or [])
|
||||||
|
if ids and purge_ids:
|
||||||
|
print("choose either task_ids to archive or --rm archived task_ids", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if not ids and not purge_ids:
|
||||||
print("at least one task_id is required", file=sys.stderr)
|
print("at least one task_id is required", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
failed: list[str] = []
|
failed: list[str] = []
|
||||||
with kb.connect() as conn:
|
with kb.connect() as conn:
|
||||||
|
if purge_ids:
|
||||||
|
for tid in purge_ids:
|
||||||
|
if not kb.delete_archived_task(conn, tid):
|
||||||
|
failed.append(tid)
|
||||||
|
print(f"cannot delete {tid} (must already be archived)", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(f"Deleted {tid}")
|
||||||
|
return 0 if not failed else 1
|
||||||
for tid in ids:
|
for tid in ids:
|
||||||
if not kb.archive_task(conn, tid):
|
if not kb.archive_task(conn, tid):
|
||||||
failed.append(tid)
|
failed.append(tid)
|
||||||
|
|||||||
@@ -3014,6 +3014,32 @@ def archive_task(conn: sqlite3.Connection, task_id: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def delete_archived_task(conn: sqlite3.Connection, task_id: str) -> bool:
|
||||||
|
"""Permanently remove an already-archived task and its related rows.
|
||||||
|
|
||||||
|
Safety guard: only archived tasks can be deleted. Active / blocked / done
|
||||||
|
tasks must be explicitly archived first so accidental data loss requires a
|
||||||
|
second deliberate action.
|
||||||
|
"""
|
||||||
|
with write_txn(conn):
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT status FROM tasks WHERE id = ?",
|
||||||
|
(task_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not row or row["status"] != "archived":
|
||||||
|
return False
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM task_links WHERE parent_id = ? OR child_id = ?",
|
||||||
|
(task_id, task_id),
|
||||||
|
)
|
||||||
|
conn.execute("DELETE FROM task_comments WHERE task_id = ?", (task_id,))
|
||||||
|
conn.execute("DELETE FROM task_events WHERE task_id = ?", (task_id,))
|
||||||
|
conn.execute("DELETE FROM task_runs WHERE task_id = ?", (task_id,))
|
||||||
|
conn.execute("DELETE FROM kanban_notify_subs WHERE task_id = ?", (task_id,))
|
||||||
|
cur = conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
|
||||||
|
return cur.rowcount == 1
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Workspace resolution
|
# Workspace resolution
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -761,6 +761,37 @@ def test_cli_archive_bulk(kanban_home):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_archive_rm_deletes_archived_tasks(kanban_home):
|
||||||
|
conn = kb.connect()
|
||||||
|
try:
|
||||||
|
tid = kb.create_task(conn, title="gone")
|
||||||
|
assert kb.archive_task(conn, tid)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
out = run_slash(f"archive --rm {tid}")
|
||||||
|
assert f"Deleted {tid}" in out
|
||||||
|
conn = kb.connect()
|
||||||
|
try:
|
||||||
|
assert kb.get_task(conn, tid) is None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_archive_rm_rejects_live_tasks(kanban_home):
|
||||||
|
conn = kb.connect()
|
||||||
|
try:
|
||||||
|
tid = kb.create_task(conn, title="still-live")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
out = run_slash(f"archive --rm {tid}")
|
||||||
|
assert "cannot delete" in out.lower()
|
||||||
|
conn = kb.connect()
|
||||||
|
try:
|
||||||
|
assert kb.get_task(conn, tid) is not None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def test_cli_unblock_bulk(kanban_home):
|
def test_cli_unblock_bulk(kanban_home):
|
||||||
conn = kb.connect()
|
conn = kb.connect()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -534,6 +534,37 @@ def test_archive_hides_from_default_list(kanban_home):
|
|||||||
assert len(kb.list_tasks(conn, include_archived=True)) == 1
|
assert len(kb.list_tasks(conn, include_archived=True)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_archived_task_removes_related_rows(kanban_home):
|
||||||
|
with kb.connect() as conn:
|
||||||
|
parent = kb.create_task(conn, title="parent")
|
||||||
|
tid = kb.create_task(conn, title="child", parents=[parent], assignee="worker")
|
||||||
|
kb.add_comment(conn, tid, "user", "cleanup me")
|
||||||
|
kb.claim_task(conn, tid)
|
||||||
|
kb.complete_task(conn, tid, result="done")
|
||||||
|
assert kb.archive_task(conn, tid)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO kanban_notify_subs(task_id, platform, chat_id, thread_id, user_id, created_at, last_event_id) "
|
||||||
|
"VALUES (?, 'telegram', '123', '', 'u', 0, 0)",
|
||||||
|
(tid,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
assert kb.delete_archived_task(conn, tid) is True
|
||||||
|
assert kb.get_task(conn, tid) is None
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM task_links WHERE child_id = ? OR parent_id = ?", (tid, tid)).fetchone()[0] == 0
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM task_comments WHERE task_id = ?", (tid,)).fetchone()[0] == 0
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM task_events WHERE task_id = ?", (tid,)).fetchone()[0] == 0
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM task_runs WHERE task_id = ?", (tid,)).fetchone()[0] == 0
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM kanban_notify_subs WHERE task_id = ?", (tid,)).fetchone()[0] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_archived_task_rejects_non_archived_rows(kanban_home):
|
||||||
|
with kb.connect() as conn:
|
||||||
|
tid = kb.create_task(conn, title="live")
|
||||||
|
assert kb.delete_archived_task(conn, tid) is False
|
||||||
|
assert kb.get_task(conn, tid) is not None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Comments / events / worker context
|
# Comments / events / worker context
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user