diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index 79519831bc..e23eedc7fa 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -326,6 +326,44 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu b_set_wd.add_argument("path", nargs="?", default=None, help="Absolute path to use as default workdir. Omit to clear.") + b_export = boards_sub.add_parser( + "export", + help="Export a board to a portable .tar.gz archive", + description=( + "Package a board's tasks, comments, links, history, and file " + "attachments into one archive that can be imported on another " + "machine. Claims, worker PIDs, chat subscriptions, and paths " + "belonging to this machine are stripped. Workspaces are never " + "included — they are rebuilt on demand." + ), + ) + b_export.add_argument("slug", nargs="?", default=None, + help="Board to export (default: the current board)") + b_export.add_argument("-o", "--output", default=None, + help="Archive path (default: ./.tar.gz)") + b_export.add_argument("--no-attachments", action="store_true", + help="Skip attachment files, keeping the archive small") + b_export.add_argument("--include-logs", action="store_true", + help="Include per-task worker logs") + b_export.add_argument("--json", action="store_true") + + b_import = boards_sub.add_parser( + "import", + help="Import a board archive as a new board", + description=( + "Import a .tar.gz produced by `hermes kanban boards export`. " + "The board always lands as a NEW board — the slug gains a " + "numeric suffix if it is already taken — so an import can " + "never overwrite or merge into a board you already have." + ), + ) + b_import.add_argument("archive", help="Path to the .tar.gz archive") + b_import.add_argument("--as", dest="as_slug", default=None, + help="Slug for the imported board (default: from the archive)") + b_import.add_argument("--switch", action="store_true", + help="Switch to the imported board afterwards") + b_import.add_argument("--json", action="store_true") + # --- create --- p_create = sub.add_parser("create", help="Create a new task") p_create.add_argument("title", help="Task title") @@ -1268,6 +1306,10 @@ def _dispatch_boards(args: argparse.Namespace) -> int: return _cmd_boards_rename(args) if sub == "set-default-workdir": return _cmd_boards_set_default_workdir(args) + if sub == "export": + return _cmd_boards_export(args) + if sub == "import": + return _cmd_boards_import(args) print(f"kanban boards: unknown action {sub!r}", file=sys.stderr) return 2 @@ -1443,6 +1485,64 @@ def _cmd_boards_set_default_workdir(args: argparse.Namespace) -> int: return 0 +def _cmd_boards_export(args: argparse.Namespace) -> int: + from hermes_cli import kanban_transfer + from hermes_cli.sizefmt import format_bytes + + slug = args.slug or kb.get_current_board() + output = args.output or f"{slug}.tar.gz" + try: + res = kanban_transfer.export_board( + slug, + output, + include_attachments=not args.no_attachments, + include_logs=args.include_logs, + ) + except (OSError, ValueError) as exc: + print(f"kanban boards export: {exc}", file=sys.stderr) + return 1 + + if getattr(args, "json", False): + print(json.dumps(res, indent=2, ensure_ascii=False)) + return 0 + counts = res["counts"] + print(f"Exported board {res['board']!r} → {res['archive']}") + print(f" Size: {format_bytes(res['size'])}") + print(f" Tasks: {counts['tasks']}") + print(f" Comments: {counts['task_comments']}") + print(f" Attachments: {counts['attachment_files']}") + print("Import it with `hermes kanban boards import `.") + return 0 + + +def _cmd_boards_import(args: argparse.Namespace) -> int: + from hermes_cli import kanban_transfer + + try: + res = kanban_transfer.import_board( + args.archive, args.as_slug, activate=args.switch + ) + except (OSError, ValueError) as exc: + print(f"kanban boards import: {exc}", file=sys.stderr) + return 1 + + if getattr(args, "json", False): + print(json.dumps(res, indent=2, ensure_ascii=False)) + return 0 + print(f"Imported board {res['board']!r} ({res['name']}).") + if res["renamed"]: + print(f" Renamed from {res['requested_board']!r} — that slug was taken.") + print(f" Path: {res['path']}") + print(f" Tasks: {res['counts']['tasks']}") + for warning in res["warnings"]: + print(f" Note: {warning}") + if res["activated"]: + print(f" Active board is now {res['board']!r}.") + else: + print(f" Switch to it with `hermes kanban boards switch {res['board']}`.") + return 0 + + # --------------------------------------------------------------------------- diff --git a/hermes_cli/kanban_transfer.py b/hermes_cli/kanban_transfer.py new file mode 100644 index 0000000000..a5bf3aaab4 --- /dev/null +++ b/hermes_cli/kanban_transfer.py @@ -0,0 +1,478 @@ +"""Kanban board export / import — move a whole board between machines. + +Backs ``hermes kanban export|import``, the matching ``/boards/{slug}/export`` +and ``/boards/import`` REST endpoints, and the desktop board switcher's +Export/Import items. + +Archive layout (``.tar.gz``, one top-level directory named for the +source board's slug):: + + / + manifest.json format + version + provenance + row counts + board.json display metadata, machine-local fields stripped + kanban.db consistent snapshot of the board database + attachments//… attachment blobs (unless --no-attachments) + logs/.log worker logs (only with --include-logs) + +Two things make this more than a ``tar czf`` of the board directory. + +**The database is live.** Kanban runs in WAL mode and a dispatcher may be +mid-write, so copying ``kanban.db`` off the filesystem yields a torn +snapshot that is missing whatever still sits in the ``-wal`` file. Export +goes through SQLite's online-backup API instead, which produces a +consistent single-file image of a database that is being written to. + +**Rows carry machine-local state.** Claims, PIDs, heartbeats, absolute +workspace and attachment paths, gateway chat subscriptions, and session +ids are all meaningful only on the machine that wrote them. Shipping them +verbatim is how an imported board arrives holding claims owned by a +process on somebody else's laptop, or starts pushing task events into a +stranger's Telegram thread. Everything machine-local is scrubbed on the +export side (so the archive itself never carries it) and defensively +re-scrubbed on import; see :func:`_scrub_local_state` and +:func:`_relocate_imported_rows`. + +Imports always land as a **new** board — the slug auto-suffixes on +collision — so an import can never mutate a board that is already there. +That also means an imported board is never ``default``, which is what +lets the import side ignore the default board's split on-disk layout +(``/kanban.db`` beside ``/kanban/attachments/``) and put +everything inside one ``boards//`` directory. +""" + +from __future__ import annotations + +import contextlib +import json +import shutil +import sqlite3 +import tempfile +import time +from pathlib import Path +from typing import Any, Optional + +from hermes_cli import kanban_db as kb +from hermes_cli.archive_safe import ( + archive_root_dirs, + copy_regular_files, + make_targz, + safe_extract_targz, +) + +ARCHIVE_FORMAT = "hermes-kanban-board" +ARCHIVE_FORMAT_VERSION = 1 + +# Statuses from which the dispatcher can still act on a task. A task whose +# workspace cannot be rebuilt on this machine is parked in ``triage`` only +# if it is in one of these — terminal and already-parked tasks are left +# alone rather than having their history rewritten. +_DISPATCHABLE_STATUSES = ("ready", "running", "todo", "scheduled") + + +# --------------------------------------------------------------------------- +# Export +# --------------------------------------------------------------------------- + +def _snapshot_db(source: Path, target: Path) -> None: + """Write a consistent copy of ``source`` to ``target``. + + Uses SQLite's online-backup API rather than a file copy: in WAL mode + a just-committed page can still live in the ``-wal`` sidecar, so + copying only ``kanban.db`` loses recent writes and can produce a + torn image if the dispatcher commits mid-copy. + """ + src = sqlite3.connect(str(source)) + try: + dst = sqlite3.connect(str(target)) + try: + src.backup(dst) + finally: + dst.close() + finally: + src.close() + + +def _scrub_local_state(conn: sqlite3.Connection) -> None: + """Strip machine-local runtime state. Caller owns the transaction. + + Runs on the export side so the archive itself never carries another + machine's claims, PIDs, or — the one that actually matters for a + board shared with someone else — the gateway chat ids subscribed to + its task events. Repeated on import because an archive is untrusted + input. + """ + conn.execute("DELETE FROM kanban_notify_subs") + conn.execute( + """ + UPDATE tasks + SET claim_lock = NULL, + claim_expires = NULL, + worker_pid = NULL, + current_run_id = NULL, + last_heartbeat_at = NULL, + session_id = NULL, + project_id = NULL, + consecutive_failures = 0, + last_failure_error = NULL + """ + ) + # A task caught mid-run is not running anywhere the importer can see. + # Send it back to the queue rather than shipping a phantom claim. + conn.execute("UPDATE tasks SET status = 'ready' WHERE status = 'running'") + conn.execute( + """ + UPDATE task_runs + SET status = 'released', + outcome = COALESCE(outcome, 'reclaimed'), + ended_at = COALESCE(ended_at, ?), + last_heartbeat_at = NULL + WHERE status = 'running' + """, + (int(time.time()),), + ) + conn.execute("UPDATE task_runs SET claim_lock = NULL, worker_pid = NULL") + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.write_text( + json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + + +def _count_rows(conn: sqlite3.Connection) -> dict[str, int]: + tables = ( + "tasks", "task_links", "task_comments", + "task_events", "task_runs", "task_attachments", + ) + return { + t: int(conn.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0]) + for t in tables + } + + +def export_board( + board: Optional[str], + output_path: str, + *, + include_attachments: bool = True, + include_logs: bool = False, +) -> dict[str, Any]: + """Export ``board`` to a ``tar.gz`` archive. Returns a summary dict. + + ``output_path`` may be given with or without the ``.tar.gz`` suffix. + Workspaces are never included: they are git worktrees and scratch + trees that are large, machine-local, and rebuilt on demand. + """ + slug = kb._normalize_board_slug(board) or kb.get_current_board() + if not kb.board_exists(slug): + raise ValueError(f"board {slug!r} does not exist") + + db_path = kb.kanban_db_path(slug) + if not db_path.exists(): + raise FileNotFoundError(f"board {slug!r} has no database at {db_path}") + + output = Path(output_path).expanduser() + base = str(output).removesuffix(".tar.gz").removesuffix(".tgz") + Path(base).parent.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory() as tmpdir: + staged = Path(tmpdir) / slug + staged.mkdir(parents=True) + + _snapshot_db(db_path, staged / "kanban.db") + # The snapshot is a private file with no other writers, so plain + # commit/close is enough — no need for the board DB's WAL dance. + with contextlib.closing(sqlite3.connect(str(staged / "kanban.db"))) as snapshot: + _scrub_local_state(snapshot) + snapshot.commit() + counts = _count_rows(snapshot) + + meta = kb.read_board_metadata(slug) + # Both name a location on the exporting machine; the importer + # resolves its own. + meta.pop("db_path", None) + meta["default_workdir"] = None + meta["project_id"] = None + _write_json(staged / "board.json", meta) + + attachments = 0 + if include_attachments: + attachments = copy_regular_files( + kb.attachments_root(slug), staged / "attachments" + ) + logs = 0 + if include_logs: + logs = copy_regular_files( + kb.worker_logs_dir(slug), staged / "logs" + ) + + try: + from hermes_cli import __version__ as hermes_version + except Exception: + hermes_version = "" + + manifest = { + "format": ARCHIVE_FORMAT, + "format_version": ARCHIVE_FORMAT_VERSION, + "board": slug, + "board_name": meta.get("name") or slug, + "exported_at": int(time.time()), + "hermes_version": str(hermes_version), + "includes": { + "attachments": bool(include_attachments), + "logs": bool(include_logs), + }, + "counts": {**counts, "attachment_files": attachments, "log_files": logs}, + } + _write_json(staged / "manifest.json", manifest) + + archive = make_targz(base, tmpdir, slug) + + return { + "board": slug, + "archive": archive, + "size": Path(archive).stat().st_size, + "counts": manifest["counts"], + } + + +# --------------------------------------------------------------------------- +# Import +# --------------------------------------------------------------------------- + +def _available_slug(preferred: str) -> str: + """Return ``preferred``, or the first free ``-N`` variant. + + ``default`` always reports as existing, so an archive exported from a + default board naturally lands as ``default-2`` instead of colliding + with the importer's own default board. + """ + if not kb.board_exists(preferred): + return preferred + # Leave headroom for the suffix inside the 64-char slug limit. + stem = preferred[:58].rstrip("-_") or "board" + n = 2 + while True: + candidate = f"{stem}-{n}" + if not kb.board_exists(candidate): + return candidate + n += 1 + + +def _read_manifest(root: Path) -> dict[str, Any]: + path = root / "manifest.json" + if not path.exists(): + raise ValueError( + "archive is not a Hermes kanban board export (no manifest.json)" + ) + try: + manifest = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ValueError(f"archive manifest is not valid JSON: {exc}") from exc + if not isinstance(manifest, dict) or manifest.get("format") != ARCHIVE_FORMAT: + raise ValueError( + "archive is not a Hermes kanban board export " + f"(format={manifest.get('format') if isinstance(manifest, dict) else None!r})" + ) + version = manifest.get("format_version") + if not isinstance(version, int) or version > ARCHIVE_FORMAT_VERSION: + raise ValueError( + f"archive format version {version!r} is newer than this Hermes " + f"understands (max {ARCHIVE_FORMAT_VERSION}) — update Hermes and retry" + ) + return manifest + + +def _read_board_metadata(path: Path) -> dict[str, Any]: + """Read an archive's ``board.json``, tolerating a missing/broken file.""" + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + return raw if isinstance(raw, dict) else {} + + +def _relocate_imported_rows( + conn: sqlite3.Connection, slug: str +) -> tuple[dict[str, int], list[str]]: + """Re-anchor an imported board's rows to this machine. + + Returns ``(stats, warnings)``. Three things move: + + * Attachment rows are repointed at this board's attachments tree. + Rows whose blob did not travel (an export made with + ``--no-attachments``) are dropped, because a row pointing at a file + that does not exist breaks download in every UI that lists it. + * Workspace paths are cleared. ``scratch`` tasks regenerate one under + this board on the next claim, so they are simply reset. ``dir`` and + ``worktree`` tasks cannot be resolved without a path that means + something here, so any that are still dispatchable are parked in + ``triage`` — otherwise the dispatcher claims them, fails to build a + workspace, and burns them straight into the failure breaker. + * Runtime state is scrubbed again. Export already did this, but an + archive is an untrusted input and the cost is one UPDATE. + """ + warnings: list[str] = [] + now = int(time.time()) + attachments_dir = kb.attachments_root(slug) + + with kb.write_txn(conn): + _scrub_local_state(conn) + + dropped = 0 + rehomed = 0 + for row in conn.execute( + "SELECT id, task_id, stored_path FROM task_attachments" + ).fetchall(): + landed = attachments_dir / row["task_id"] / Path(row["stored_path"]).name + if landed.is_file(): + conn.execute( + "UPDATE task_attachments SET stored_path = ? WHERE id = ?", + (str(landed), row["id"]), + ) + rehomed += 1 + else: + conn.execute( + "DELETE FROM task_attachments WHERE id = ?", (row["id"],) + ) + dropped += 1 + if dropped: + warnings.append( + f"{dropped} attachment record(s) dropped — the files were not " + f"in the archive" + ) + + parked = [ + r["id"] + for r in conn.execute( + "SELECT id FROM tasks WHERE workspace_kind IN ('dir', 'worktree') " + f"AND status IN ({', '.join('?' * len(_DISPATCHABLE_STATUSES))})", + _DISPATCHABLE_STATUSES, + ).fetchall() + ] + conn.execute("UPDATE tasks SET workspace_path = NULL, branch_name = NULL") + if parked: + conn.execute( + f"UPDATE tasks SET status = 'triage' " + f"WHERE id IN ({', '.join('?' * len(parked))})", + parked, + ) + warnings.append( + f"{len(parked)} task(s) moved to triage — their workspace was a " + f"directory or git worktree on the exporting machine and needs " + f"to be pointed somewhere on this one" + ) + + for row in conn.execute("SELECT id FROM tasks").fetchall(): + conn.execute( + "INSERT INTO task_events (task_id, run_id, kind, payload, created_at) " + "VALUES (?, NULL, 'imported', ?, ?)", + ( + row["id"], + json.dumps( + { + "board": slug, + "parked": row["id"] in parked, + }, + ensure_ascii=False, + ), + now, + ), + ) + + return {"attachments": rehomed, "parked": len(parked)}, warnings + + +def import_board( + archive_path: str, + slug: Optional[str] = None, + *, + activate: bool = False, +) -> dict[str, Any]: + """Import a board archive as a new board. Returns a summary dict. + + ``slug`` overrides the name from the archive. Either way the final + slug auto-suffixes if it is taken, so an import never merges into or + overwrites an existing board. + """ + archive = Path(archive_path).expanduser() + if not archive.exists(): + raise FileNotFoundError(f"archive not found: {archive}") + + roots = archive_root_dirs(archive) + if len(roots) != 1: + raise ValueError( + "a kanban board archive must contain exactly one top-level directory" + ) + archive_root = roots.pop() + + with tempfile.TemporaryDirectory() as tmpdir: + staging = Path(tmpdir) + safe_extract_targz(archive, staging) + extracted = staging / archive_root + + manifest = _read_manifest(extracted) + staged_db = extracted / "kanban.db" + if not staged_db.is_file(): + raise ValueError("archive is missing kanban.db") + + requested = kb._normalize_board_slug( + slug or manifest.get("board") or archive_root + ) + if not requested: + raise ValueError( + "cannot determine a board name from the archive — pass one " + "explicitly with --as " + ) + target = _available_slug(requested) + + staged_meta = _read_board_metadata(extracted / "board.json") + + board_root = kb.board_dir(target) + board_root.mkdir(parents=True, exist_ok=True) + shutil.move(str(staged_db), str(board_root / "kanban.db")) + for tree in ("attachments", "logs"): + src = extracted / tree + if src.is_dir(): + shutil.move(str(src), str(board_root / tree)) + + # Rewritten rather than moved across: the archive's copy names a slug + # and a workdir that belong to the exporting machine. + name = str(staged_meta.get("name") or manifest.get("board_name") or target) + kb.write_board_metadata( + target, + name=name, + description=str(staged_meta.get("description") or ""), + icon=str(staged_meta.get("icon") or ""), + color=str(staged_meta.get("color") or ""), + archived=False, + ) + # Bring the imported schema up to this install's version before the + # relocation pass writes to it. + kb.init_db(board=target) + + with kb.connect_closing(board=target) as conn: + stats, warnings = _relocate_imported_rows(conn, target) + counts = _count_rows(conn) + + if activate: + kb.set_current_board(target) + + return { + "board": target, + "requested_board": requested, + "renamed": target != requested, + "name": name, + "path": str(kb.board_dir(target)), + "db_path": str(kb.kanban_db_path(target)), + "source": { + "board": manifest.get("board"), + "exported_at": manifest.get("exported_at"), + "hermes_version": manifest.get("hermes_version"), + }, + "counts": counts, + "attachments_restored": stats["attachments"], + "tasks_parked": stats["parked"], + "warnings": warnings, + "activated": bool(activate), + } diff --git a/tests/hermes_cli/test_kanban_transfer.py b/tests/hermes_cli/test_kanban_transfer.py new file mode 100644 index 0000000000..fdf37c582e --- /dev/null +++ b/tests/hermes_cli/test_kanban_transfer.py @@ -0,0 +1,364 @@ +"""Tests for kanban board export / import (``hermes_cli.kanban_transfer``). + +The contract these pin down is "a board survives the trip to another +machine, and nothing that only made sense on the exporting machine comes +with it": + +* Content round-trips — tasks, comments, links, events, attachment blobs. +* Runtime state does not — claims, worker PIDs, heartbeats, session ids, + and gateway chat subscriptions are gone on the far side. +* Paths are re-anchored — attachment rows point into the importing + board's tree, and tasks whose workspace cannot be rebuilt here are + parked instead of being fed to the dispatcher. +* An import never mutates a board that already exists. +* A hostile archive cannot write outside the import destination. +""" + +from __future__ import annotations + +import json +import sys +import tarfile +import time +from pathlib import Path + +import pytest + +# Ensure the worktree (not the stale global clone) is first on sys.path. +_WORKTREE = Path(__file__).resolve().parents[2] +if str(_WORKTREE) not in sys.path: + sys.path.insert(0, str(_WORKTREE)) + +from hermes_cli import kanban_db as kb +from hermes_cli import kanban_transfer as kt +from hermes_cli.archive_safe import normalize_archive_parts, safe_extract_targz + + +@pytest.fixture +def kanban_root(tmp_path, monkeypatch): + """Point kanban at an empty root, and hand back a switcher. + + Export and import have to run against two different machines' state. + Calling the returned function re-points every kanban path helper at a + fresh root, which is as close to "the other machine" as a unit test + gets. + """ + def _use(name: str) -> Path: + root = tmp_path / name + root.mkdir(exist_ok=True) + monkeypatch.setenv("HERMES_HOME", str(root)) + monkeypatch.setenv("HERMES_KANBAN_HOME", str(root)) + for var in ("HERMES_KANBAN_DB", "HERMES_KANBAN_WORKSPACES_ROOT", + "HERMES_KANBAN_ATTACHMENTS_ROOT", "HERMES_KANBAN_BOARD"): + monkeypatch.delenv(var, raising=False) + kb._INITIALIZED_PATHS.clear() + return root + + _use("source") + return _use + + +def _seed_board(slug: str = "alpha") -> dict[str, str]: + """Create a board with one task of each interesting shape.""" + kb.create_board(slug, name="Alpha Board") + ids = {} + with kb.connect_closing(board=slug) as conn: + ids["scratch"] = kb.create_task( + conn, title="scratch task", body="body", assignee="coder" + ) + ids["worktree"] = kb.create_task( + conn, title="worktree task", assignee="coder", + workspace_kind="worktree", workspace_path="/exporter/repo", + ) + kb.add_comment(conn, ids["scratch"], "brooklyn", "a comment") + kb.link_tasks(conn, ids["scratch"], ids["worktree"]) + kb.store_attachment_bytes( + conn, ids["scratch"], "notes.txt", b"hello attachment", board=slug + ) + return ids + + +def _claim(task_id: str, slug: str = "alpha") -> None: + """Put a task into the state a live worker would leave behind.""" + with kb.connect_closing(board=slug) as conn: + with kb.write_txn(conn): + conn.execute( + "UPDATE tasks SET status='running', claim_lock='lock-1', " + "claim_expires=?, worker_pid=4242, last_heartbeat_at=?, " + "session_id='sess-xyz', consecutive_failures=2 WHERE id=?", + (int(time.time()) + 600, int(time.time()), task_id), + ) + + +def _subscribe(task_id: str, slug: str = "alpha") -> None: + with kb.connect_closing(board=slug) as conn: + with kb.write_txn(conn): + conn.execute( + "INSERT INTO kanban_notify_subs " + "(task_id, platform, chat_id, thread_id, created_at) " + "VALUES (?, 'telegram', '12345', '', ?)", + (task_id, int(time.time())), + ) + + +def _tasks_by_title(slug: str) -> dict[str, dict]: + with kb.connect_closing(board=slug) as conn: + return { + row["title"]: dict(row) + for row in conn.execute("SELECT * FROM tasks").fetchall() + } + + +# --------------------------------------------------------------------------- +# Round trip +# --------------------------------------------------------------------------- + +def test_round_trip_preserves_content(kanban_root, tmp_path): + _seed_board() + archive = kt.export_board("alpha", str(tmp_path / "alpha"))["archive"] + + kanban_root("target") + result = kt.import_board(archive) + + assert result["counts"]["tasks"] == 2 + assert result["counts"]["task_comments"] == 1 + assert result["counts"]["task_links"] == 1 + + tasks = _tasks_by_title(result["board"]) + assert set(tasks) == {"scratch task", "worktree task"} + assert tasks["scratch task"]["body"] == "body" + assert tasks["scratch task"]["assignee"] == "coder" + + +def test_attachment_blob_travels_and_is_readable(kanban_root, tmp_path): + _seed_board() + archive = kt.export_board("alpha", str(tmp_path / "alpha"))["archive"] + + target_root = kanban_root("target") + result = kt.import_board(archive) + + with kb.connect_closing(board=result["board"]) as conn: + row = conn.execute( + "SELECT filename, stored_path FROM task_attachments" + ).fetchone() + + assert row["filename"] == "notes.txt" + stored = Path(row["stored_path"]) + # Re-anchored under the importing machine's board, not the exporter's. + assert target_root in stored.parents + assert stored.read_bytes() == b"hello attachment" + + +def test_export_without_attachments_drops_the_rows(kanban_root, tmp_path): + _seed_board() + archive = kt.export_board( + "alpha", str(tmp_path / "alpha"), include_attachments=False + )["archive"] + + kanban_root("target") + result = kt.import_board(archive) + + # A row whose blob never travelled would be a broken download link in + # every UI that lists it, so it is dropped and reported. + assert result["counts"]["task_attachments"] == 0 + assert any("attachment" in w for w in result["warnings"]) + + +def test_workspaces_are_never_exported(kanban_root, tmp_path): + _seed_board() + workspace = kb.workspaces_root("alpha") / "junk" + workspace.mkdir(parents=True) + (workspace / "huge.bin").write_bytes(b"x" * 1024) + + archive = kt.export_board("alpha", str(tmp_path / "alpha"))["archive"] + + with tarfile.open(archive, "r:gz") as tf: + names = tf.getnames() + assert not any("workspaces" in name for name in names) + + +# --------------------------------------------------------------------------- +# Machine-local state does not travel +# --------------------------------------------------------------------------- + +def test_claimed_task_arrives_unclaimed_and_queued(kanban_root, tmp_path): + ids = _seed_board() + _claim(ids["scratch"]) + archive = kt.export_board("alpha", str(tmp_path / "alpha"))["archive"] + + kanban_root("target") + result = kt.import_board(archive) + + task = _tasks_by_title(result["board"])["scratch task"] + # A claim held by a PID on the exporting machine must not survive, or + # the importing dispatcher inherits a lock nothing will ever release. + assert task["status"] == "ready" + assert task["claim_lock"] is None + assert task["claim_expires"] is None + assert task["worker_pid"] is None + assert task["last_heartbeat_at"] is None + assert task["current_run_id"] is None + assert task["session_id"] is None + assert task["consecutive_failures"] == 0 + + +def test_gateway_subscriptions_never_travel(kanban_root, tmp_path): + ids = _seed_board() + _subscribe(ids["scratch"]) + archive = kt.export_board("alpha", str(tmp_path / "alpha"))["archive"] + + # Not merely dropped on import — the chat id must not be in the file + # at all, because the archive is the thing that gets shared. + kanban_root("target") + result = kt.import_board(archive) + with kb.connect_closing(board=result["board"]) as conn: + assert conn.execute( + "SELECT COUNT(*) FROM kanban_notify_subs" + ).fetchone()[0] == 0 + + assert b"12345" not in Path(archive).read_bytes() + + +def test_unresolvable_workspaces_are_parked_not_dispatched(kanban_root, tmp_path): + _seed_board() + archive = kt.export_board("alpha", str(tmp_path / "alpha"))["archive"] + + kanban_root("target") + result = kt.import_board(archive) + tasks = _tasks_by_title(result["board"]) + + # The worktree lived on the exporting machine's disk. Letting the + # dispatcher claim this would fail workspace resolution twice and trip + # the failure breaker, so it waits for a human instead. + assert tasks["worktree task"]["status"] == "triage" + assert tasks["worktree task"]["workspace_path"] is None + assert result["tasks_parked"] == 1 + + # A scratch task needs no path — it regenerates one under this board. + assert tasks["scratch task"]["status"] == "ready" + assert tasks["scratch task"]["workspace_path"] is None + + +def test_board_metadata_loses_exporter_local_paths(kanban_root, tmp_path): + kb.create_board("alpha", name="Alpha Board", + default_workdir="/exporter/repo", project_id="proj-1") + archive = kt.export_board("alpha", str(tmp_path / "alpha"))["archive"] + + kanban_root("target") + result = kt.import_board(archive) + meta = kb.read_board_metadata(result["board"]) + + assert meta["name"] == "Alpha Board" + assert meta["default_workdir"] is None + assert meta["project_id"] is None + + +# --------------------------------------------------------------------------- +# Import never overwrites +# --------------------------------------------------------------------------- + +def test_slug_collision_creates_a_new_board(kanban_root, tmp_path): + _seed_board() + archive = kt.export_board("alpha", str(tmp_path / "alpha"))["archive"] + + kanban_root("target") + first = kt.import_board(archive) + second = kt.import_board(archive) + + assert first["board"] == "alpha" + assert second["board"] != first["board"] + assert second["renamed"] is True + assert second["requested_board"] == "alpha" + # The board that was already there is untouched. + assert len(_tasks_by_title(first["board"])) == 2 + + +def test_import_never_targets_the_default_board(kanban_root, tmp_path): + with kb.connect_closing(board="default") as conn: + kb.create_task(conn, title="exported default task") + archive = kt.export_board("default", str(tmp_path / "default"))["archive"] + + kanban_root("target") + with kb.connect_closing(board="default") as conn: + kb.create_task(conn, title="local default task") + + result = kt.import_board(archive) + + assert result["board"] != "default" + assert set(_tasks_by_title("default")) == {"local default task"} + assert set(_tasks_by_title(result["board"])) == {"exported default task"} + + +def test_explicit_slug_is_honoured(kanban_root, tmp_path): + _seed_board() + archive = kt.export_board("alpha", str(tmp_path / "alpha"))["archive"] + + kanban_root("target") + result = kt.import_board(archive, "renamed-board", activate=True) + + assert result["board"] == "renamed-board" + assert result["renamed"] is False + assert kb.get_current_board() == "renamed-board" + + +# --------------------------------------------------------------------------- +# Hostile / malformed input +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("member", [ + "../escape.txt", + "board/../../escape.txt", + "/etc/passwd", + "C:\\Windows\\system32", + "board\\..\\..\\escape.txt", +]) +def test_traversal_members_are_rejected(member): + with pytest.raises(ValueError): + normalize_archive_parts(member) + + +def test_extract_refuses_a_symlink_member(tmp_path): + payload = tmp_path / "payload" + payload.mkdir() + (payload / "real.txt").write_text("fine") + link = payload / "link" + link.symlink_to("/etc/passwd") + + archive = tmp_path / "evil.tar.gz" + with tarfile.open(archive, "w:gz") as tf: + tf.add(payload, arcname="payload") + + with pytest.raises(ValueError, match="Unsupported archive member"): + safe_extract_targz(archive, tmp_path / "out") + + +def test_import_rejects_a_non_kanban_archive(kanban_root, tmp_path): + payload = tmp_path / "notaboard" + payload.mkdir() + (payload / "readme.txt").write_text("hi") + archive = tmp_path / "other.tar.gz" + with tarfile.open(archive, "w:gz") as tf: + tf.add(payload, arcname="notaboard") + + with pytest.raises(ValueError, match="manifest"): + kt.import_board(str(archive)) + + +def test_import_rejects_a_future_format_version(kanban_root, tmp_path): + _seed_board() + archive = Path(kt.export_board("alpha", str(tmp_path / "alpha"))["archive"]) + + staged = tmp_path / "restage" + safe_extract_targz(archive, staged) + manifest_path = staged / "alpha" / "manifest.json" + manifest = json.loads(manifest_path.read_text()) + manifest["format_version"] = kt.ARCHIVE_FORMAT_VERSION + 1 + manifest_path.write_text(json.dumps(manifest)) + + bumped = tmp_path / "bumped.tar.gz" + with tarfile.open(bumped, "w:gz") as tf: + tf.add(staged / "alpha", arcname="alpha") + + kanban_root("target") + with pytest.raises(ValueError, match="newer than this Hermes"): + kt.import_board(str(bumped))