From 4465b8d7d5807769b8d0e48e9ee8fdeec237c247 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:01:38 -0700 Subject: [PATCH] fix(kanban): BLOB cells in comment/event/run rows degrade like a BLOB task body Only Task.from_row coerced BLOB-typed cells; a BLOB task_comments.body (or event payload / run summary) still came back as bytes and crashed `hermes kanban show --json` with "Object of type bytes is not JSON serializable". Apply _lossy_text in the other from_row constructors. Test: BLOB comment body and event payload -> str with U+FFFD and JSON-serialisable (red before). --- hermes_cli/kanban_db.py | 15 +++++++------ .../test_kanban_list_undecodable_text.py | 22 ++++++++++++++++++- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 560def8a57..4f4b625f74 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -38,7 +38,8 @@ def _lossy_text(value: Any) -> Any: 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 + to UTF-8") and applied to BLOB-typed cells in the ``from_row`` constructors + (task, comment, event, run), so one corrupt row degrades to replacement characters instead of taking the board listing down.""" if isinstance(value, bytes): @@ -789,7 +790,7 @@ class Run: def from_row(cls, row: sqlite3.Row) -> "Run": return cls( **{ - col: row[col] for col in ( + col: _lossy_text(row[col]) for col in ( "task_id", "profile", "step_key", "status", "claim_lock", "claim_expires", "worker_pid", "max_runtime_seconds", "last_heartbeat_at", "outcome", "summary", "error", ) @@ -797,7 +798,7 @@ class Run: id=int(row["id"]), started_at=int(row["started_at"]), ended_at=_opt_int(row["ended_at"]), - metadata=_json_or(row["metadata"]), + metadata=_json_or(_lossy_text(row["metadata"])), ) @@ -812,8 +813,8 @@ class Comment: @classmethod def from_row(cls, r: sqlite3.Row) -> "Comment": return cls( - id=r["id"], task_id=r["task_id"], author=r["author"], - body=r["body"], created_at=r["created_at"], + id=r["id"], task_id=r["task_id"], author=_lossy_text(r["author"]), + body=_lossy_text(r["body"]), created_at=r["created_at"], ) @@ -852,8 +853,8 @@ class Event: def from_row(cls, row: sqlite3.Row) -> "Event": run_id = _row_get(row, "run_id") return cls( - id=row["id"], task_id=row["task_id"], kind=row["kind"], - payload=_json_or(row["payload"]), created_at=row["created_at"], run_id=_opt_int(run_id), + id=row["id"], task_id=row["task_id"], kind=_lossy_text(row["kind"]), + payload=_json_or(_lossy_text(row["payload"])), created_at=row["created_at"], run_id=_opt_int(run_id), ) diff --git a/tests/hermes_cli/test_kanban_list_undecodable_text.py b/tests/hermes_cli/test_kanban_list_undecodable_text.py index 517d020493..caeda16a14 100644 --- a/tests/hermes_cli/test_kanban_list_undecodable_text.py +++ b/tests/hermes_cli/test_kanban_list_undecodable_text.py @@ -5,7 +5,8 @@ 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. +(U+FFFD) and the ``from_row`` constructors (task, comment, event, run) coerce +BLOB cells the same way, so ``show --json`` survives a BLOB comment too. """ from __future__ import annotations @@ -52,3 +53,22 @@ def test_list_survives_undecodable_and_blob_text_cells(board): 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" + + +def test_show_json_survives_blob_cells_in_sibling_tables(board): + with kbc.connect() as conn: + tid = kb.create_task(conn, title="card", assignee="coder") + conn.execute( + "INSERT INTO task_comments (task_id, author, body, created_at) " + "VALUES (?, 'user', X'FFFEABCD00', 1000)", (tid,), + ) + conn.execute( + "INSERT INTO task_events (task_id, kind, payload, created_at) " + "VALUES (?, 'note', X'FFFEABCD00', 1000)", (tid,), + ) + + with kbc.connect() as conn: + comments = kb.list_comments(conn, tid) + assert isinstance(comments[-1].body, str) and "\ufffd" in comments[-1].body + events = kb.list_events(conn, tid) + json.dumps([dataclasses.asdict(c) for c in comments] + [dataclasses.asdict(e) for e in events])