fix(kanban): one undecodable task field no longer breaks the whole board listing

A tasks row whose TEXT body holds invalid UTF-8 made sqlite3 raise
"Could not decode to UTF-8 column 'body'" inside fetchall, so `hermes kanban
list` (and `show`, and every other reader of that row) failed board-wide
until the row was deleted by hand; a BLOB-typed body came back as bytes and
crashed `--json` (#111743).

Fix it once at the connection: every board connection (`_open_configured`
and the read-only descendant path in `connect`) installs a lossy
text_factory that substitutes U+FFFD, and `Task.from_row` runs BLOB cells
through the same helper so a corrupt row renders with replacement
characters instead of taking its neighbours down.

Fixes #111743
This commit is contained in:
teknium1
2026-09-15 12:09:59 -07:00
committed by Teknium
parent 85d4415bed
commit a647c6cb2d
3 changed files with 71 additions and 2 deletions
@@ -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"