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:
+15
-2
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user