Merge pull request #134 from abhi1693/fix/blocked-transition-error-schema
Fix blocked-task error schema: include code field
This commit is contained in:
@@ -55,6 +55,7 @@ class BlockedTaskDetail(SQLModel):
|
||||
"""Error detail payload listing blocking dependency task identifiers."""
|
||||
|
||||
message: str
|
||||
code: str | None = None
|
||||
blocked_by_task_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
|
||||
34
backend/tests/test_openapi_blocked_task_error_schema.py
Normal file
34
backend/tests/test_openapi_blocked_task_error_schema.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# ruff: noqa: INP001, S101
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def test_openapi_blocked_task_error_includes_code_field() -> None:
|
||||
schema = app.openapi()
|
||||
|
||||
blocked_detail = schema["components"]["schemas"]["BlockedTaskDetail"]
|
||||
props = blocked_detail.get("properties", {})
|
||||
|
||||
# `code` is optional but must be documented for clients.
|
||||
assert "code" in props
|
||||
required_fields = blocked_detail.get("required", [])
|
||||
assert "code" not in required_fields
|
||||
|
||||
code_schema = props["code"]
|
||||
any_of = code_schema.get("anyOf")
|
||||
if any_of:
|
||||
assert isinstance(any_of, list)
|
||||
|
||||
has_string_branch = any(branch.get("type") == "string" for branch in any_of)
|
||||
assert has_string_branch
|
||||
|
||||
has_null_branch = any(
|
||||
branch.get("type") == "null" or branch.get("nullable") is True for branch in any_of
|
||||
)
|
||||
assert has_null_branch
|
||||
else:
|
||||
# Alternative encoding used by some schema versions for Optional[str].
|
||||
assert code_schema.get("type") == "string"
|
||||
assert code_schema.get("nullable") is True
|
||||
138
backend/tests/test_tasks_blocked_lead_dependency_only.py
Normal file
138
backend/tests/test_tasks_blocked_lead_dependency_only.py
Normal file
@@ -0,0 +1,138 @@
|
||||
# ruff: noqa: INP001
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
from sqlmodel import SQLModel, col, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.api.deps import ActorContext
|
||||
from app.api.tasks import _apply_lead_task_update, _TaskUpdateInput
|
||||
from app.models.agents import Agent
|
||||
from app.models.boards import Board
|
||||
from app.models.organizations import Organization
|
||||
from app.models.task_dependencies import TaskDependency
|
||||
from app.models.tasks import Task
|
||||
from app.services.task_dependencies import blocked_by_for_task
|
||||
|
||||
|
||||
async def _make_engine() -> AsyncEngine:
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.connect() as conn, conn.begin():
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
return engine
|
||||
|
||||
|
||||
async def _make_session(engine: AsyncEngine) -> AsyncSession:
|
||||
return AsyncSession(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lead_dependency_only_update_allowed_when_task_blocked() -> None:
|
||||
"""Leads may update dependencies even if the task is currently blocked.
|
||||
|
||||
This supports unblocking work by adjusting dependency graphs, while still
|
||||
rejecting status/assignee transitions.
|
||||
"""
|
||||
|
||||
engine = await _make_engine()
|
||||
try:
|
||||
async with await _make_session(engine) as session:
|
||||
org_id = uuid4()
|
||||
board_id = uuid4()
|
||||
lead_id = uuid4()
|
||||
dep_id = uuid4()
|
||||
task_id = uuid4()
|
||||
|
||||
session.add(Organization(id=org_id, name="org"))
|
||||
session.add(Board(id=board_id, organization_id=org_id, name="b", slug="b"))
|
||||
session.add(
|
||||
Agent(
|
||||
id=lead_id,
|
||||
name="Lead",
|
||||
board_id=board_id,
|
||||
gateway_id=uuid4(),
|
||||
is_board_lead=True,
|
||||
openclaw_session_id="agent:lead:session",
|
||||
),
|
||||
)
|
||||
session.add(
|
||||
Task(
|
||||
id=dep_id,
|
||||
board_id=board_id,
|
||||
title="dep",
|
||||
description=None,
|
||||
status="inbox",
|
||||
),
|
||||
)
|
||||
session.add(
|
||||
Task(
|
||||
id=task_id,
|
||||
board_id=board_id,
|
||||
title="t",
|
||||
description=None,
|
||||
status="review",
|
||||
assigned_agent_id=None,
|
||||
),
|
||||
)
|
||||
session.add(
|
||||
TaskDependency(
|
||||
board_id=board_id,
|
||||
task_id=task_id,
|
||||
depends_on_task_id=dep_id,
|
||||
),
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
lead = (await session.exec(select(Agent).where(col(Agent.id) == lead_id))).first()
|
||||
task = (await session.exec(select(Task).where(col(Task.id) == task_id))).first()
|
||||
assert lead is not None
|
||||
assert task is not None
|
||||
blocked_by_before = await blocked_by_for_task(
|
||||
session,
|
||||
board_id=board_id,
|
||||
task_id=task_id,
|
||||
)
|
||||
assert blocked_by_before == [dep_id]
|
||||
|
||||
# Re-assert the same deps list; this should be a no-op and should not
|
||||
# be rejected solely because the task is blocked.
|
||||
update = _TaskUpdateInput(
|
||||
task=task,
|
||||
actor=ActorContext(actor_type="agent", agent=lead),
|
||||
board_id=board_id,
|
||||
previous_status=task.status,
|
||||
previous_assigned=task.assigned_agent_id,
|
||||
status_requested=False,
|
||||
updates={},
|
||||
comment=None,
|
||||
depends_on_task_ids=[dep_id],
|
||||
tag_ids=None,
|
||||
custom_field_values={},
|
||||
custom_field_values_set=False,
|
||||
)
|
||||
|
||||
result = await _apply_lead_task_update(session, update=update)
|
||||
assert result.id == task_id
|
||||
assert result.is_blocked is True
|
||||
assert result.blocked_by_task_ids == [dep_id]
|
||||
|
||||
reloaded = (await session.exec(select(Task).where(col(Task.id) == task_id))).first()
|
||||
assert reloaded is not None
|
||||
assert reloaded.status == "review"
|
||||
assert reloaded.assigned_agent_id is None
|
||||
dependency_rows = (
|
||||
await session.exec(
|
||||
select(TaskDependency).where(
|
||||
col(TaskDependency.task_id) == task_id,
|
||||
col(TaskDependency.depends_on_task_id) == dep_id,
|
||||
),
|
||||
)
|
||||
).all()
|
||||
assert len(dependency_rows) == 1
|
||||
|
||||
finally:
|
||||
await engine.dispose()
|
||||
Reference in New Issue
Block a user