diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index f593a4279f..560def8a57 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -33,6 +33,19 @@ _log = logging.getLogger(__name__) # --- Shared micro-helpers (row access, JSON, env, git) --- +def _lossy_text(value: Any) -> Any: + """``bytes`` -> ``str`` with U+FFFD for undecodable sequences; anything else passes through. + + Installed as every board connection's ``text_factory`` (a TEXT cell holding + invalid UTF-8 otherwise aborts the whole ``fetchall`` with "Could not decode + to UTF-8") and applied to BLOB-typed cells in :meth:`Task.from_row`, so one + corrupt row degrades to replacement characters instead of taking the board + listing down.""" + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return value + + def _row_get(row: Any, col: str, default: Any = None) -> Any: """``row[col]`` tolerant of the column being absent from the SELECT / schema.""" if row is None or col not in row.keys(): @@ -716,11 +729,11 @@ class Task: @classmethod def from_row(cls, row: sqlite3.Row) -> "Task": - g = lambda col, default=None: _row_get(row, col, default) # noqa: E731 + g = lambda col, default=None: _lossy_text(_row_get(row, col, default)) # noqa: E731 parsed = _json_or(g("skills")) skills_value = [str(s) for s in parsed if s] if isinstance(parsed, list) else None return cls( - **{col: row[col] for col in _TASK_REQUIRED_COLUMNS}, + **{col: _lossy_text(row[col]) for col in _TASK_REQUIRED_COLUMNS}, **{col: g(col) for col in _TASK_OPTIONAL_COLUMNS}, **{col: g(col) or None for col in _TASK_EMPTY_IS_NULL_COLUMNS}, # Pre-migration fallbacks (spawn_failures / last_spawn_error) are only diff --git a/hermes_cli/kanban_db_connect.py b/hermes_cli/kanban_db_connect.py index 998d96abbb..1f863ecd4a 100644 --- a/hermes_cli/kanban_db_connect.py +++ b/hermes_cli/kanban_db_connect.py @@ -639,6 +639,7 @@ def _open_configured(path: Path, under_lock) -> tuple[sqlite3.Connection, Any]: conn = _sqlite_connect(path) try: conn.row_factory = sqlite3.Row + conn.text_factory = _kb._lossy_text with _INIT_LOCK: # WAL doesn't work on network filesystems; the helper falls back to # DELETE with one ERROR log (see hermes_state_wal._WAL_INCOMPAT_MARKERS). @@ -678,6 +679,7 @@ def connect(db_path: Optional[Path] = None, *, board: Optional[str] = None) -> s # missing board or migrate on a descendant's behalf; the owner initializes it. conn = sqlite3.connect(path.resolve().as_uri() + "?mode=ro", uri=True) conn.row_factory = sqlite3.Row + conn.text_factory = _kb._lossy_text if not _schema_is_present(conn): conn.close() raise PermissionError("Kanban descendants require an initialized board; ask its owner to initialize it") diff --git a/tests/hermes_cli/test_kanban_list_undecodable_text.py b/tests/hermes_cli/test_kanban_list_undecodable_text.py new file mode 100644 index 0000000000..517d020493 --- /dev/null +++ b/tests/hermes_cli/test_kanban_list_undecodable_text.py @@ -0,0 +1,54 @@ +"""Invariant: one task row with an undecodable text field cannot take down the +board listing (issue #111743). + +A ``body`` stored as TEXT holding invalid UTF-8 made sqlite3 abort the whole +``fetchall`` ("Could not decode to UTF-8 column 'body'"), so ``hermes kanban list`` +failed for every card until the row was deleted by hand; a BLOB-typed body came +back as ``bytes`` and crashed ``--json``. Board connections now decode lossily +(U+FFFD) and ``Task.from_row`` coerces BLOB cells the same way. +""" + +from __future__ import annotations + +import dataclasses +import json +from pathlib import Path + +import pytest + +from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_connect as kbc + + +@pytest.fixture +def board(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + db_path = kb.kanban_db_path(board="default") + kb._INITIALIZED_PATHS.discard(str(db_path.resolve())) + kb.init_db() + return db_path + + +def test_list_survives_undecodable_and_blob_text_cells(board): + with kbc.connect() as conn: + good = kb.create_task(conn, title="good card", assignee="coder") + conn.execute( + "INSERT INTO tasks (id, title, body, assignee, status, priority, created_by, created_at) " + "VALUES ('t_text_bad', 'text invalid', CAST(X'FFFEABCD00' AS TEXT), 'coder', 'ready', 0, 'user', 1000)" + ) + conn.execute( + "INSERT INTO tasks (id, title, body, assignee, status, priority, created_by, created_at) " + "VALUES ('t_blob_bad', 'blob body', X'FFFEABCD00', 'coder', 'ready', 0, 'user', 1000)" + ) + + with kbc.connect() as conn: + tasks = {t.id: t for t in kb.list_tasks(conn, status="ready")} + assert {good, "t_text_bad", "t_blob_bad"} <= set(tasks) + for tid in ("t_text_bad", "t_blob_bad"): + body = tasks[tid].body + assert isinstance(body, str) and "\ufffd" in body + json.dumps(dataclasses.asdict(tasks[tid])) # the --json path + assert kb.get_task(conn, "t_text_bad").title == "text invalid"