Files
hermes-agent/hermes_cli/kanban_transfer.py
Teknium ff660354f3 refactor(kanban): CLI micro-helpers, action dispatch tables, shared triage helpers; active_sessions dedupe
hermes_cli/kanban.py 3,565 -> 2,912; kanban_diagnostics 1,216 -> 996;
kanban_decompose 468 -> 393; kanban_transfer 478 -> 443; kanban_specify
264 -> 229; kanban_swarm 390 -> 378; active_sessions 871 -> 775. `hermes kanban
[sub] --help` byte-identical for all 55 parsers.

- kanban.py: _err / _print_json / _json_out / _fmt_counts / _bulk_apply /
  _obj_dict field tuples replace repeated print/JSON/exit-code blocks; action
  and board subcommand routing via dict dispatch; shared run-state and
  triage-sweep argparse blocks; argparse declarations re-packed (AST-identical).
- specify/decompose: one _run_triage_sweep driver, shared _extract_json_blob /
  _truncate / _profile_author / _title_body / _resolve_profile_from_cfg.
- diagnostics: rule helpers (_first_field / _latest_event_ts / _log_hint_action
  / _error_snippet), _rows_by_task fleet fetch; unreferenced DIAGNOSTIC_KINDS dropped.
- swarm: graph nodes share one create_task kwarg set.
- active_sessions: one _flock per platform, _pid_alive via _pid_liveness,
  shared _read_live_entries / _without_lease / _clean_metadata, table-driven
  strict registry validation.
- Docstrings/comments hand-compacted (AST-identical), invariants kept.
2026-09-02 13:32:14 -07:00

444 lines
16 KiB
Python

"""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 ``tar czf`` of the board directory:
* **The database is live** (WAL mode, dispatcher may be mid-write), so the
export uses SQLite's online-backup API for a consistent image instead of a
file copy that would miss the ``-wal`` sidecar.
* **Rows carry machine-local state** — claims, PIDs, heartbeats, absolute
paths, gateway chat subscriptions, session ids. Shipping them verbatim
would import claims owned by a stranger's process or push events into a
stranger's Telegram thread. Scrubbed on export and re-scrubbed on import
(an archive is untrusted); see :func:`_scrub_local_state` and
:func:`_relocate_imported_rows`.
Imports always land as a **new** board (slug auto-suffixes on collision), so
an import never mutates an existing board and is never ``default`` — which
lets the importer ignore the default board's split on-disk layout.
"""
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:
"""Consistent copy of ``source`` via the online-backup API (a file copy
would miss pages still in the ``-wal`` sidecar and could tear)."""
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 (claims, PIDs, and above all the
gateway chat ids subscribed to task events). Caller owns the transaction.
Run on export and again on import (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`` (suffix optional on ``output_path``);
returns a summary dict. Workspaces are never included — large,
machine-local, 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:
"""``preferred`` or the first free ``<preferred>-N``. ``default`` always
exists, so a default-board export lands as ``default-2``."""
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)``.
* Attachment rows are repointed at this board's tree; rows whose blob
did not travel (``--no-attachments``) are dropped, since a dangling row
breaks download in every UI.
* Workspace paths are cleared. ``scratch`` regenerates on next claim;
dispatchable ``dir``/``worktree`` tasks are parked in ``triage``,
otherwise the dispatcher claims them, fails to build a workspace, and
burns them into the failure breaker.
* Runtime state is scrubbed again (untrusted input, 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 an archive as a NEW board (``slug`` overrides the archive's;
either way it auto-suffixes if taken). Returns a summary dict."""
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),
}