feat(kanban): export and import a whole board as a portable archive
`hermes kanban boards export|import` moves a board between machines: tasks, comments, links, history, and attachments in one .tar.gz. Two things make this more than a tar of the board directory. The database is live — kanban runs in WAL mode, so a filesystem copy loses whatever still sits in the -wal sidecar and tears if the dispatcher commits mid-copy; export goes through SQLite's online-backup API instead. And rows carry machine-local state: claims, worker PIDs, absolute workspace and attachment paths, session ids, and the gateway chat ids subscribed to task events. Shipping those verbatim is how an imported board arrives holding a claim owned by a process on someone else's laptop, or starts pushing task events into a stranger's Telegram thread. Everything machine-local is stripped on export and re-stripped on import, since an archive is untrusted input. Imports always land as a NEW board, auto-suffixing the slug on collision, so an import can never merge into or overwrite a board that is already there. Tasks whose workspace was a directory or git worktree on the source machine are parked in triage rather than left for the dispatcher to claim and burn into the failure breaker.
This commit is contained in:
committed by
brooklyn!
parent
110ecd238e
commit
3150e444b2
@@ -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: ./<slug>.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 <archive>`.")
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
478
hermes_cli/kanban_transfer.py
Normal file
478
hermes_cli/kanban_transfer.py
Normal file
@@ -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 (``<slug>.tar.gz``, one top-level directory named for the
|
||||
source board's slug)::
|
||||
|
||||
<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/<task>/… attachment blobs (unless --no-attachments)
|
||||
logs/<task>.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
|
||||
(``<root>/kanban.db`` beside ``<root>/kanban/attachments/``) and put
|
||||
everything inside one ``boards/<slug>/`` 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 ``<preferred>-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 <slug>"
|
||||
)
|
||||
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),
|
||||
}
|
||||
364
tests/hermes_cli/test_kanban_transfer.py
Normal file
364
tests/hermes_cli/test_kanban_transfer.py
Normal file
@@ -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))
|
||||
Reference in New Issue
Block a user