Fix smriti current crash on list-valued blocked_by

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.
This commit is contained in:
Himanshu Dongre 2026-05-17 16:20:32 +05:30
parent 93c8ef6a4e
commit 2bbb7d27bc
2 changed files with 59 additions and 1 deletions

View file

@ -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

View file

@ -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")