From 2bbb7d27bc2e7019be7a855c36f26067ba4d4266 Mon Sep 17 00:00:00 2001 From: Himanshu Dongre Date: Sun, 17 May 2026 16:20:32 +0530 Subject: [PATCH] Fix smriti current crash on list-valued blocked_by MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External-machine validation found GET /api/v5/current 500s on a real project: a structured task can carry `blocked_by` as a list of dependency labels, but CurrentTask.blocked_by is typed Optional[str], so constructing the model raised a Pydantic ValidationError. Add a field validator that normalizes `blocked_by` — string, list, or null — to a single display string. The API contract is unchanged (blocked_by stays a string), so the CLI and the Project Current State UI panel, which both read this endpoint, need no change. Adds a regression test. --- backend/app/api/routes/current.py | 21 +++++++++- .../tests/integration/test_current_state.py | 39 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/backend/app/api/routes/current.py b/backend/app/api/routes/current.py index de49d80..866b0a5 100644 --- a/backend/app/api/routes/current.py +++ b/backend/app/api/routes/current.py @@ -17,7 +17,7 @@ from datetime import datetime from typing import Optional from fastapi import APIRouter, Depends -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from sqlalchemy import select from sqlalchemy.orm import Session @@ -108,6 +108,25 @@ class CurrentTask(BaseModel): blocked_by: Optional[str] = None status: str = "open" + @field_validator("blocked_by", mode="before") + @classmethod + def _coerce_blocked_by(cls, value: object) -> Optional[str]: + """Normalize blocked_by from whatever real task payloads carry. + + A structured task's `blocked_by` shows up as a string, a list of + dependency labels (a task blocked by several others), or null. + Coerce any of them to a single display string so the current-state + surface never 500s on a list-valued blocked_by. + """ + if value is None: + return None + if isinstance(value, str): + return value.strip() or None + if isinstance(value, (list, tuple)): + labels = [str(item).strip() for item in value if str(item).strip()] + return ", ".join(labels) or None + return str(value).strip() or None + class RecentActivityEntry(BaseModel): """One recent checkpoint, newest-first. Spans all branches so the diff --git a/backend/tests/integration/test_current_state.py b/backend/tests/integration/test_current_state.py index 244a464..c69fbe7 100644 --- a/backend/tests/integration/test_current_state.py +++ b/backend/tests/integration/test_current_state.py @@ -201,6 +201,45 @@ def test_current_open_tasks_accept_legacy_intent_type(client): assert cur["open_tasks_by_intent"]["implement"][0]["id"] == "middleware" +def test_current_handles_list_valued_blocked_by(client): + """Real projects produce tasks whose `blocked_by` is a list of + dependency labels. The current-state surface must normalize that to a + display string, not 500 on it (regression: HTTP 500 from a Pydantic + ValidationError when blocked_by was a list).""" + repo_id = _create_repo(client, "List Blocked-By") + session_id = _create_session(client, repo_id) + _commit( + client, + repo_id, + session_id, + message="Tasks with varied blocked_by shapes", + tasks=[ + { + "text": "Wire the limiter into the gateway", + "intent_hint": "implement", + "id": "wire-gateway", + "status": "open", + "blocked_by": ["middleware", "load-test"], + }, + { + "text": "Single-dependency task", + "intent_hint": "implement", + "id": "single-dep", + "status": "open", + "blocked_by": "wire-gateway", + }, + ], + ) + + # Pre-fix, a list-valued blocked_by raised a Pydantic ValidationError + # and this request 500ed; _get_current asserts a 200. + cur = _get_current(client, repo_id) + + by_id = {t["id"]: t for t in cur["open_tasks_by_intent"]["implement"]} + assert by_id["wire-gateway"]["blocked_by"] == "middleware, load-test" + assert by_id["single-dep"]["blocked_by"] == "wire-gateway" + + def test_current_recent_milestones(client): """Milestone notes surface in recent_milestones; plain notes do not.""" repo_id = _create_repo(client, "Milestones")