diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index c6f9688ffe5..0937fc40f2e 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -880,6 +880,25 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu p_gc.add_argument("--log-retention-days", type=int, default=30, help="Delete worker log files older than N days (default: 30)") + # --- repair --- + p_repair = sub.add_parser( + "repair", + help="Check kanban.db integrity and auto-repair index-only corruption", + description=( + "Runs PRAGMA integrity_check on the board's DB and reports the " + "result. When the failure consists only of index-scoped errors " + "('wrong # of entries in index ' / 'row N missing from " + "index '), the corrupt file is quarantined to a " + ".corrupt..bak sibling first and the damaged indexes are " + "rebuilt with REINDEX — the same narrow auto-repair the " + "connect-time guard applies. Any other corruption class is " + "reported and left untouched (fail-closed). Exits 0 when the DB " + "is healthy or was repaired, non-zero when it is still corrupt." + ), + ) + p_repair.add_argument("--json", action="store_true", + help="Emit the repair report as JSON") + kanban_parser.set_defaults(_kanban_parser=kanban_parser) return kanban_parser @@ -950,6 +969,12 @@ def kanban_command(args: argparse.Namespace) -> int: # schema creation; `create` / `list` / every other command would # error out on a fresh install. with board_scope: + # `repair` must dispatch BEFORE the auto-init below: on a corrupt DB + # init_db() itself raises KanbanDbCorruptError, which would turn + # every `hermes kanban repair` into "could not initialize database" + # without ever reaching the repair path. + if action == "repair": + return _cmd_repair(args) try: kb.init_db() except Exception as exc: @@ -2886,6 +2911,76 @@ def _cmd_gc(args: argparse.Namespace) -> int: return 0 +def _cmd_repair(args: argparse.Namespace) -> int: + """Check DB integrity and apply the narrow index-REINDEX auto-repair. + + Dispatched BEFORE the auto ``kb.init_db()`` in :func:`kanban_command` + (init itself refuses corrupt DBs), so this is reachable on exactly the + boards that need it. Exit codes: 0 = healthy / repaired / no DB file, + 1 = still corrupt (non-index corruption, or REINDEX did not produce a + clean re-check). + """ + try: + report = kb.repair_db() + except Exception as exc: # locked/busy probe, unexpected I/O + print(f"kanban repair: {exc}", file=sys.stderr) + return 1 + + if getattr(args, "json", False): + print(json.dumps({ + "status": report.status, + "db_path": str(report.db_path), + "messages": report.messages, + "post_repair_messages": report.post_repair_messages, + "backup_path": ( + str(report.backup_path) if report.backup_path else None + ), + "reindexed": report.reindexed, + }, indent=2)) + return 0 if report.status in {"ok", "repaired", "missing"} else 1 + + if report.status == "missing": + print(f"No kanban DB at {report.db_path} — nothing to repair.") + return 0 + if report.status == "ok": + print(f"{report.db_path}: integrity_check ok — no repair needed.") + return 0 + if report.status == "repaired": + print(f"{report.db_path}: repaired.") + print(f" reindexed: {', '.join(report.reindexed)}") + if report.backup_path: + print(f" pre-repair backup: {report.backup_path}") + print(" integrity_check now ok.") + return 0 + # still corrupt + print(f"{report.db_path}: CORRUPT.", file=sys.stderr) + for line in (report.messages or [])[:10]: + print(f" {line}", file=sys.stderr) + if report.reindexed: + print( + f" REINDEX ({', '.join(report.reindexed)}) attempted but " + f"integrity_check is still failing:", + file=sys.stderr, + ) + for line in (report.post_repair_messages or [])[:10]: + print(f" {line}", file=sys.stderr) + else: + print( + " Not an index-only failure — automatic REINDEX repair does " + "not apply (fail-closed).", + file=sys.stderr, + ) + if report.backup_path: + print(f" corrupt copy quarantined at: {report.backup_path}", + file=sys.stderr) + print( + " Recover manually (e.g. `sqlite3 kanban.db \".recover\"` into a " + "fresh file) or move the file aside to start a new board.", + file=sys.stderr, + ) + return 1 + + # --------------------------------------------------------------------------- # Slash-command entry point (used by /kanban from CLI and gateway) # --------------------------------------------------------------------------- diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index a45c385db69..a99cda57286 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -1907,6 +1907,112 @@ def _guard_existing_db_is_healthy(path: Path) -> None: raise KanbanDbCorruptError(resolved, backup, reason) +@dataclass +class RepairResult: + """Outcome of :func:`repair_db` for CLI/status reporting. + + ``status`` is one of: + + * ``"ok"`` — integrity_check was already clean; nothing done. + * ``"repaired"`` — index-only errors found, REINDEX applied, re-check + clean. ``backup_path`` holds the pre-repair quarantine copy. + * ``"corrupt"`` — still corrupt: either a non-index error class + (fail-closed, no repair attempted) or a REINDEX whose re-check did + not come back clean. + * ``"missing"`` — no DB file (or zero-byte placeholder); nothing to do. + """ + + status: str + db_path: Path + messages: list[str] = field(default_factory=list) + post_repair_messages: list[str] = field(default_factory=list) + backup_path: Optional[Path] = None + reindexed: list[str] = field(default_factory=list) + + +def repair_db( + db_path: Optional[Path] = None, + *, + board: Optional[str] = None, +) -> RepairResult: + """Probe a kanban DB and apply the narrow index-REINDEX repair if needed. + + Shares the exact policy of :func:`_guard_existing_db_is_healthy`: only + integrity failures composed *entirely* of index-scoped errors are + repairable; the corrupt bytes are quarantined via + :func:`_backup_corrupt_db` BEFORE any mutation; the REINDEX runs under + the board's cross-process init flock; and anything else stays corrupt + (fail-closed) for the caller to surface. Unlike the guard this never + raises :class:`KanbanDbCorruptError` — it returns a structured + :class:`RepairResult` so ``hermes kanban repair`` can report and choose + its own exit code. + + Transient ``sqlite3.OperationalError`` (locked/busy) still propagates + raw, exactly like the guard: a locked healthy DB is not corruption and + must not be quarantined. + """ + if db_path is not None: + path = db_path + else: + path = kanban_db_path(board=board) + try: + resolved = path.resolve() + except OSError: + resolved = path + try: + if not resolved.exists() or resolved.stat().st_size == 0: + return RepairResult(status="missing", db_path=resolved) + except OSError: + return RepairResult(status="missing", db_path=resolved) + + with _cross_process_init_lock(resolved): + messages: list[str] = [] + try: + probe = _sqlite_connect(resolved) + try: + messages = _run_integrity_check(probe) + finally: + probe.close() + except sqlite3.OperationalError: + # Locked/busy — not corruption; let the caller report it raw. + raise + except sqlite3.DatabaseError as exc: + # Same quarantine the connect-time guard takes for a file + # sqlite refuses to open at all (e.g. malformed page 1). + return RepairResult( + status="corrupt", + db_path=resolved, + messages=[f"sqlite refused to open file: {exc}"], + backup_path=_backup_corrupt_db(resolved), + ) + if _integrity_messages_ok(messages): + return RepairResult(status="ok", db_path=resolved, messages=messages) + + # Quarantine FIRST — identical policy to the connect-time guard. + backup = _backup_corrupt_db(resolved) + index_names = _repairable_index_names(messages) + if not index_names: + return RepairResult( + status="corrupt", + db_path=resolved, + messages=messages, + backup_path=backup, + ) + repaired, post = _attempt_index_reindex_repair(resolved, index_names) + # The file changed on disk; force the next connect() in this process + # to re-probe instead of trusting the stale healthy-path cache. + with _INIT_LOCK: + _INITIALIZED_PATHS.discard(str(resolved)) + return RepairResult( + status="repaired" if repaired else "corrupt", + db_path=resolved, + messages=messages, + post_repair_messages=post, + backup_path=backup, + reindexed=index_names, + ) + + def connect( db_path: Optional[Path] = None, *, diff --git a/tests/hermes_cli/test_kanban_db_repair.py b/tests/hermes_cli/test_kanban_db_repair.py index 2b7147dc4ea..b6ee83197c1 100644 --- a/tests/hermes_cli/test_kanban_db_repair.py +++ b/tests/hermes_cli/test_kanban_db_repair.py @@ -3,6 +3,7 @@ and the ``hermes kanban repair`` CLI verb.""" from __future__ import annotations +import json import sqlite3 from pathlib import Path @@ -376,3 +377,133 @@ def test_wal_checkpoint_truncates_wal_file(tmp_path, monkeypatch): ) finally: conn.close() + + +# --------------------------------------------------------------------------- +# repair_db() API + `hermes kanban repair` CLI verb +# --------------------------------------------------------------------------- + +def _run_kanban_cli(argv: list[str]) -> int: + """Drive the real argparse surface exactly like `hermes kanban …`.""" + import argparse + + from hermes_cli import kanban as kc + + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command") + kc.build_parser(sub) + args = parser.parse_args(["kanban", *argv]) + return kc.kanban_command(args) + + +@pytest.fixture +def cli_home(tmp_path, monkeypatch): + """Isolated HERMES_HOME so kanban_db_path() resolves inside tmp_path.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + return home + + +def test_repair_db_reports_ok_on_healthy_board(tmp_path): + db_path = tmp_path / "kanban.db" + _build_board_db(db_path) + report = kb.repair_db(db_path=db_path) + assert report.status == "ok" + assert report.messages == ["ok"] + assert report.backup_path is None + + +def test_repair_db_missing_file(tmp_path): + report = kb.repair_db(db_path=tmp_path / "nope.db") + assert report.status == "missing" + + +def test_repair_db_repairs_index_corruption_with_backup_first(tmp_path): + db_path = tmp_path / "kanban.db" + _build_board_db(db_path) + _corrupt_index(db_path, "idx_tasks_status") + + report = kb.repair_db(db_path=db_path) + assert report.status == "repaired" + assert report.reindexed == ["idx_tasks_status"] + assert report.backup_path is not None and report.backup_path.exists() + # Backup captured the PRE-repair bytes (still corrupt in the copy). + assert any( + m.startswith("wrong # of entries in index") + for m in _integrity_messages(report.backup_path) + ) + # Live DB is clean and data intact. + assert _integrity_messages(db_path) == ["ok"] + conn = kb.connect(db_path=db_path) + try: + assert "task-0" in {t.title for t in kb.list_tasks(conn)} + finally: + conn.close() + + +def test_repair_db_fail_closed_on_page_corruption(tmp_path): + db_path = tmp_path / "kanban.db" + original = _write_page_corrupt_db(db_path) + report = kb.repair_db(db_path=db_path) + assert report.status == "corrupt" + assert report.reindexed == [] + assert report.backup_path is not None and report.backup_path.exists() + # No REINDEX mutation happened on the live file. + assert db_path.read_bytes() == original + + +def test_cli_repair_ok_exit_zero(cli_home, capsys): + kb.init_db() + rc = _run_kanban_cli(["repair"]) + out = capsys.readouterr().out + assert rc == 0 + assert "integrity_check ok" in out + + +def test_cli_repair_repairs_and_exits_zero(cli_home, capsys): + db_path = kb.kanban_db_path() + _build_board_db(db_path) + _corrupt_index(db_path, "idx_tasks_status") + + rc = _run_kanban_cli(["repair"]) + out = capsys.readouterr().out + assert rc == 0 + assert "repaired" in out + assert "idx_tasks_status" in out + assert "pre-repair backup" in out + assert _integrity_messages(db_path) == ["ok"] + + +def test_cli_repair_still_corrupt_exits_nonzero(cli_home, capsys): + db_path = kb.kanban_db_path() + db_path.parent.mkdir(parents=True, exist_ok=True) + _write_page_corrupt_db(db_path) + + rc = _run_kanban_cli(["repair"]) + err = capsys.readouterr().err + assert rc != 0 + assert "CORRUPT" in err + assert "fail-closed" in err + + +def test_cli_repair_json_shape(cli_home, capsys): + db_path = kb.kanban_db_path() + _build_board_db(db_path) + _corrupt_index(db_path, "idx_tasks_status") + + rc = _run_kanban_cli(["repair", "--json"]) + payload = json.loads(capsys.readouterr().out) + assert rc == 0 + assert payload["status"] == "repaired" + assert payload["reindexed"] == ["idx_tasks_status"] + assert payload["backup_path"] + assert Path(payload["backup_path"]).exists() + + +def test_cli_repair_missing_db_exits_zero(cli_home, capsys): + rc = _run_kanban_cli(["repair"]) + out = capsys.readouterr().out + assert rc == 0 + assert "nothing to repair" in out