diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index edaee42f88..a0300daaa7 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -435,7 +435,15 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu p_unblock.add_argument("task_ids", nargs="+") 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 --- 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: 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) return 1 failed: list[str] = [] 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: if not kb.archive_task(conn, tid): failed.append(tid) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index faea0e41ac..c6722410da 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -3014,6 +3014,32 @@ def archive_task(conn: sqlite3.Connection, task_id: str) -> bool: 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 # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_kanban_core_functionality.py b/tests/hermes_cli/test_kanban_core_functionality.py index f9e05f99ba..4ca72e8ce5 100644 --- a/tests/hermes_cli/test_kanban_core_functionality.py +++ b/tests/hermes_cli/test_kanban_core_functionality.py @@ -761,6 +761,37 @@ def test_cli_archive_bulk(kanban_home): 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): conn = kb.connect() try: diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index fb1bdbf0cf..8f2a85af29 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -534,6 +534,37 @@ def test_archive_hides_from_default_list(kanban_home): 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 # ---------------------------------------------------------------------------