diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 8944c4e5007..5eb110489cd 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -16340,10 +16340,10 @@ def main(): return output_dir = Path(args.output).expanduser() if args.output else get_hermes_home() / "session-exports" - def _export_one(session_id: str): + def _export_one(session_id: str, *, include_lineage: bool = False): data = ( db.export_session_lineage(session_id) - if getattr(args, "lineage", "single") == "logical" + if include_lineage else db.export_session(session_id) ) if not data: @@ -16373,30 +16373,85 @@ def main(): print(f"Session '{args.session_id}' not found.") db.close() return - try: - data, exported_path = _export_one(resolved_session_id) - except FileExistsError as e: - print(f"Export already exists: {e}. Pass --force to overwrite.") - db.close() - return - if not data or not exported_path: - print(f"Session '{args.session_id}' not found.") - db.close() - return - message_count = len(data.get("messages") or []) - suffix = "" if message_count == 1 else "s" - print(f"Exported 1 session ({message_count} message{suffix}) to {exported_path}") + delete_target_ids = [resolved_session_id] if args.delete_after_verified: - ok, reason = verify_export_file(exported_path, data) - if not ok: - print(f"Export verification failed; not deleting: {reason}") + delete_target_ids = db.get_session_delete_targets( + resolved_session_id + ) + + exported_items = [] + for target_id in delete_target_ids: + try: + data, exported_path = _export_one( + target_id, + include_lineage=( + target_id == resolved_session_id + and getattr(args, "lineage", "single") == "logical" + ), + ) + except FileExistsError as e: + print( + f"Export already exists: {e}. " + "Pass --force to overwrite." + ) db.close() return + if not data or not exported_path: + print( + f"Session '{target_id}' disappeared during export; " + "nothing was deleted." + ) + db.close() + return + exported_items.append((data, exported_path)) + + message_count = sum( + len(data.get("messages") or []) + for data, _path in exported_items + ) + suffix = "" if message_count == 1 else "s" + if len(exported_items) == 1: + print( + f"Exported 1 session ({message_count} message{suffix}) " + f"to {exported_items[0][1]}" + ) + else: + print( + f"Exported {len(exported_items)} sessions " + f"({message_count} message{suffix}) to {output_dir}" + ) + if args.delete_after_verified: + for data, exported_path in exported_items: + ok, reason = verify_export_file(exported_path, data) + if not ok: + print( + "Export verification failed; not deleting " + f"session '{data.get('id')}': {reason}" + ) + db.close() + return sessions_dir = get_hermes_home() / "sessions" - if db.delete_session(resolved_session_id, sessions_dir=sessions_dir): - print(f"Deleted exported session '{resolved_session_id}'.") + if db.delete_session( + resolved_session_id, + sessions_dir=sessions_dir, + expected_delete_ids=delete_target_ids, + ): + delegate_count = len(delete_target_ids) - 1 + delegate_suffix = ( + "" + if not delegate_count + else f" and {delegate_count} delegate session" + f"{'' if delegate_count == 1 else 's'}" + ) + print( + f"Deleted exported session '{resolved_session_id}'" + f"{delegate_suffix}." + ) else: - print(f"Exported, but session '{resolved_session_id}' was not deleted because it was not found.") + print( + f"Exported, but session '{resolved_session_id}' was " + "not deleted because its delegate set changed." + ) db.close() return @@ -16422,7 +16477,10 @@ def main(): exported = 0 for row in candidates: try: - data, exported_path = _export_one(row["id"]) + data, exported_path = _export_one( + row["id"], + include_lineage=getattr(args, "lineage", "single") == "logical", + ) except FileExistsError as e: print(f"Skipping existing export: {e}. Pass --force to overwrite.") continue diff --git a/hermes_state.py b/hermes_state.py index 4ba71b29556..537ed280d50 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -8959,10 +8959,28 @@ class SessionDB: except OSError: pass + def get_session_delete_targets(self, session_id: str) -> List[str]: + """Return every session row that :meth:`delete_session` would remove. + + The requested session is first, followed by its recursively discovered + delegate/subagent children. Branch and compression children are not + included because deletion preserves them by orphaning their parent + reference. + """ + with self._lock: + exists = self._conn.execute( + "SELECT 1 FROM sessions WHERE id = ? LIMIT 1", (session_id,) + ).fetchone() + if not exists: + return [] + delegate_ids = _collect_delegate_child_ids(self._conn, [session_id]) + return [session_id, *sorted(delegate_ids)] + def delete_session( self, session_id: str, sessions_dir: Optional[Path] = None, + expected_delete_ids: Optional[List[str]] = None, ) -> bool: """Delete a session and all its messages. @@ -8972,9 +8990,16 @@ class SessionDB: (``parent_session_id → NULL``) so they remain accessible independently. When *sessions_dir* is provided, also removes on-disk transcript files (``.json`` / ``.jsonl`` / ``request_dump_*``) for every deleted - session. Returns True if the session was found and deleted. + session. When *expected_delete_ids* is provided, deletion proceeds only + if the parent plus delegate cascade still matches that exact set. This + lets export-before-delete callers fail closed if a new delegate appears + after they materialize their archive. Returns True if the session was + found and deleted. """ removed_delegate_ids: List[str] = [] + expected_ids = ( + set(expected_delete_ids) if expected_delete_ids is not None else None + ) def _do(conn): cursor = conn.execute( @@ -8982,6 +9007,13 @@ class SessionDB: ) if cursor.fetchone()[0] == 0: return False + if expected_ids is not None: + actual_ids = { + session_id, + *_collect_delegate_child_ids(conn, [session_id]), + } + if actual_ids != expected_ids: + return False removed_delegate_ids.extend(_delete_delegate_children(conn, [session_id])) # Orphan remaining child sessions (branches, etc.) so FK is satisfied. conn.execute( diff --git a/tests/hermes_cli/test_sessions_export_md_cli.py b/tests/hermes_cli/test_sessions_export_md_cli.py index 76c634b7105..665da274c9c 100644 --- a/tests/hermes_cli/test_sessions_export_md_cli.py +++ b/tests/hermes_cli/test_sessions_export_md_cli.py @@ -336,8 +336,12 @@ def test_sessions_export_md_delete_after_verified_deletes_after_file_check(monke def export_session(self, session_id): return {"id": "s1", "title": "Delete", "message_count": 1, "messages": [{"role": "user", "content": "safe"}]} + def get_session_delete_targets(self, session_id): + return [session_id] + def delete_session(self, session_id, **kwargs): captured["deleted"] = session_id + captured["expected_delete_ids"] = kwargs["expected_delete_ids"] return True def close(self): @@ -363,11 +367,76 @@ def test_sessions_export_md_delete_after_verified_deletes_after_file_check(monke main_mod.main() - assert captured == {"deleted": "s1"} + assert captured == {"deleted": "s1", "expected_delete_ids": ["s1"]} assert len(list(tmp_path.glob("*.md"))) == 1 assert "Deleted exported session 's1'" in capsys.readouterr().out +def test_sessions_export_md_exports_delegate_cascade_before_deleting( + monkeypatch, tmp_path, capsys +): + import hermes_cli.main as main_mod + import hermes_state + + db_path = tmp_path / "state.db" + sessions_dir = tmp_path / "sessions" + sessions_dir.mkdir() + real_session_db = hermes_state.SessionDB + db = real_session_db(db_path) + db.create_session("parent", "cli") + db.append_message("parent", "user", "parent transcript") + db.create_session( + "delegate", + "subagent", + parent_session_id="parent", + model_config={"_delegate_from": "parent"}, + ) + db.append_message("delegate", "assistant", "delegate-only result") + db.close() + (sessions_dir / "parent.jsonl").write_text("parent", encoding="utf-8") + (sessions_dir / "delegate.jsonl").write_text("delegate", encoding="utf-8") + + monkeypatch.setattr( + hermes_state, "SessionDB", lambda: real_session_db(db_path) + ) + monkeypatch.setattr(main_mod, "get_hermes_home", lambda: tmp_path) + output_dir = tmp_path / "exports" + monkeypatch.setattr( + sys, + "argv", + [ + "hermes", + "sessions", + "export", + "--format", + "md", + "--session-id", + "parent", + "--delete-after-verified", + "--yes", + str(output_dir), + ], + ) + + main_mod.main() + + exported = [ + path.read_text(encoding="utf-8") for path in output_dir.glob("*.md") + ] + assert len(exported) == 2 + assert any("parent transcript" in text for text in exported) + assert any("delegate-only result" in text for text in exported) + check = real_session_db(db_path) + assert check.get_session("parent") is None + assert check.get_session("delegate") is None + check.close() + assert not (sessions_dir / "parent.jsonl").exists() + assert not (sessions_dir / "delegate.jsonl").exists() + output = capsys.readouterr().out + assert "Exported 2 sessions (2 messages)" in output + assert "and 1 delegate session" in output + + def test_sessions_export_md_accepts_duration_age_grammar(monkeypatch, tmp_path, capsys): """--older-than accepts the same AGE grammar as prune ('2w', '5h', ISO).""" import hermes_cli.main as main_mod diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 2aa5199612a..74af22c08e1 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -4818,6 +4818,39 @@ class TestListSessionsRich: assert db.get_session("delegate") is None assert db.get_session("branch") is not None + def test_delete_session_expected_targets_fail_closed_on_new_delegate(self, db): + db.create_session("parent", "cli") + db.create_session( + "delegate", + "cli", + parent_session_id="parent", + model_config={"_delegate_from": "parent"}, + ) + db.create_session( + "branch", + "cli", + parent_session_id="parent", + model_config={"_branched_from": "parent"}, + ) + + expected_ids = db.get_session_delete_targets("parent") + assert expected_ids == ["parent", "delegate"] + + db.create_session( + "late-delegate", + "cli", + parent_session_id="parent", + model_config={"_delegate_from": "parent"}, + ) + + assert ( + db.delete_session("parent", expected_delete_ids=expected_ids) is False + ) + assert db.get_session("parent") is not None + assert db.get_session("delegate") is not None + assert db.get_session("late-delegate") is not None + assert db.get_session("branch") is not None + def test_v16_migration_tags_linked_delegate_rows(self, tmp_path): """Pre-marker linked subagent rows get tagged, then cascade with parent.""" import json diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index daece81f482..50e52363171 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -392,7 +392,7 @@ hermes sessions export --format md --model sonnet --min-messages 50 --redact hermes sessions export --format md --session-id 20250305_091523_a1b2c3d4 --delete-after-verified --yes ``` -Markdown/QMD export writes one `.md` or `.qmd` file per exported session plus a `manifest.jsonl` with the file path, message count, lineage ids, and SHA-256. Bulk export requires at least one filter; a bare bulk export is refused. `--delete-after-verified` is intentionally limited to `--session-id` and requires `--yes`. `--redact` scrubs secrets (API keys, tokens, credentials) from message content and tool output before writing — recommended for any export you plan to share. +Markdown/QMD export writes one `.md` or `.qmd` file per exported session plus a `manifest.jsonl` with the file path, message count, lineage ids, and SHA-256. Bulk export requires at least one filter; a bare bulk export is refused. `--delete-after-verified` is intentionally limited to `--session-id` and requires `--yes`. Because deleting a parent session also removes its delegate/subagent sessions, this mode exports and verifies each delegate in a separate file before deleting anything. If the delegate set changes during export, deletion is refused. `--redact` scrubs secrets (API keys, tokens, credentials) from message content and tool output before writing — recommended for any export you plan to share. ### Delete a Session