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:
Brooklyn Nicholson
2026-08-28 22:30:00 -05:00
committed by brooklyn!
parent 110ecd238e
commit 3150e444b2
3 changed files with 942 additions and 0 deletions
+100
View File
@@ -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
# ---------------------------------------------------------------------------