What happened
GET /jobs/{job_id}/findings contains a defensive two-branch block (lines 869–882) to read raw_finding_count and finding_count from a SQLite row. One branch handles dict-like aiosqlite.Row objects; the other handles plain tuples. This fragile workaround exists because db.row_factory = aiosqlite.Row is set inconsistently across connection sites in the codebase — some callers set it, some do not.
This leads to a maintenance hazard: future schema changes or new query sites can silently regress because the code silently tries a second access strategy instead of failing fast.
Steps to reproduce
The bug is structural rather than a visible crash. To observe the code path:
- Open
backend/app/main.py, lines 864–904.
- Notice
db.row_factory is not set before the SELECT job_id, raw_finding_count, finding_count query.
- The resulting
job_row is a plain tuple — the code reaches the else branch (line 878) that accesses job_row[1] and job_row[2] by index.
- Compare with
_run_repo_scan_task (line 1127) where db.row_factory = aiosqlite.Row IS explicitly set — making the two sites behave differently.
Expected behaviour
get_db() should return a connection with row_factory = aiosqlite.Row already set, giving every caller a consistent dict-like interface. The dual-branch workaround in get_findings can then be simplified to a single clean dict access.
Actual behaviour
Relevant code — backend/app/main.py, lines 869–882:
if job_row is not None:
if hasattr(job_row, "keys") or isinstance(job_row, dict):
try:
raw_finding_count = job_row["raw_finding_count"]
except (KeyError, IndexError):
pass
try:
finding_count = job_row["finding_count"]
except (KeyError, IndexError):
pass
else:
if len(job_row) > 1:
raw_finding_count = job_row[1]
if len(job_row) > 2:
finding_count = job_row[2]
get_db() in backend/app/db.py, line 118–119:
async def get_db():
return await aiosqlite.connect(DB_PATH)
# ← row_factory is NOT set here
Environment
| Field |
Value |
| OS |
Any |
| Python version |
3.10+ |
| PatchPilot version / commit |
main |
Logs
No error — the code silently falls into the tuple-index branch and works, masking the root cause.
Additional context
Fix — two changes:
backend/app/db.py — set row_factory in get_db():
async def get_db():
db = await aiosqlite.connect(DB_PATH)
db.row_factory = aiosqlite.Row
return db
backend/app/main.py — simplify the dual-branch block in get_findings to:
raw_finding_count = job_row["raw_finding_count"]
finding_count = job_row["finding_count"]
Any other callers that previously set db.row_factory = aiosqlite.Row themselves can remove that line (it becomes redundant).
Acceptance criteria:
What happened
GET /jobs/{job_id}/findingscontains a defensive two-branch block (lines 869–882) to readraw_finding_countandfinding_countfrom a SQLite row. One branch handles dict-likeaiosqlite.Rowobjects; the other handles plain tuples. This fragile workaround exists becausedb.row_factory = aiosqlite.Rowis set inconsistently across connection sites in the codebase — some callers set it, some do not.This leads to a maintenance hazard: future schema changes or new query sites can silently regress because the code silently tries a second access strategy instead of failing fast.
Steps to reproduce
The bug is structural rather than a visible crash. To observe the code path:
backend/app/main.py, lines 864–904.db.row_factoryis not set before theSELECT job_id, raw_finding_count, finding_countquery.job_rowis a plain tuple — the code reaches theelsebranch (line 878) that accessesjob_row[1]andjob_row[2]by index._run_repo_scan_task(line 1127) wheredb.row_factory = aiosqlite.RowIS explicitly set — making the two sites behave differently.Expected behaviour
get_db()should return a connection withrow_factory = aiosqlite.Rowalready set, giving every caller a consistent dict-like interface. The dual-branch workaround inget_findingscan then be simplified to a single clean dict access.Actual behaviour
Relevant code —
backend/app/main.py, lines 869–882:get_db()inbackend/app/db.py, line 118–119:Environment
mainLogs
No error — the code silently falls into the tuple-index branch and works, masking the root cause.
Additional context
Fix — two changes:
backend/app/db.py— setrow_factoryinget_db():backend/app/main.py— simplify the dual-branch block inget_findingsto:Any other callers that previously set
db.row_factory = aiosqlite.Rowthemselves can remove that line (it becomes redundant).Acceptance criteria:
get_db()setsdb.row_factory = aiosqlite.Rowbefore returning the connection.hasattr/isinstanceblock inget_findingsis removed and replaced with a single dict access.row_factorymanually.