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
24 changes: 22 additions & 2 deletions hermes_cli/kanban.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
31 changes: 31 additions & 0 deletions tests/hermes_cli/test_kanban_core_functionality.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
31 changes: 31 additions & 0 deletions tests/hermes_cli/test_kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
Loading