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 <id> --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).
This commit is contained in:
teknium1
2026-09-15 15:01:38 -07:00
committed by Teknium
parent a647c6cb2d
commit 4465b8d7d5
2 changed files with 29 additions and 8 deletions
+8 -7
View File
@@ -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),
)
@@ -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])