state-hub/api/services/wait_kind.py
tegwick b9997d6fc7 CUST-WP-0074-T02: task wait qualifiers in the hub read model
Adds tasks.decision_id and workplan_dependencies.from_task_id (migration
e8f9a0b1c2d3). A from_task_id column is needed rather than
from_workplan_id plus description: the partial unique indexes key on
(from_workplan_id, target, relationship_type), so two tasks in one
workplan waiting on the same target could not both be indexed, and the
derived wait_kind needs per-task attribution. The two existing unique
indexes are narrowed to frontmatter edges (from_task_id IS NULL) and two
task-origin counterparts are added; downgrade deletes task-origin rows
before restoring the old indexes.

POST /workplans/{id}/dependencies/ accepts from_task_id (must belong to
the from workplan; a task cannot depend on itself). TaskCreate/Update/Read
carry decision_id.

Derived read-model fields, no new write routes (rule 5):
- TaskRead.wait_kind: external | human | both | unqualified, null unless
  status is wait (dependency rows from this task / needs_human).
- WorkplanRead.blocked_kind: human | external | none, null unless status
  is blocked. Human wins over external.
Both come from api/services/wait_kind.py, applied on GET /tasks/,
GET /tasks/{id}, GET /workplans/ and GET /workplans/{id}.

The identifier-migration reference-count proof now includes from_task_id
(FK count guard 22 -> 23).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 237582@bnt-lap001
Assistant-Session: f2b3d9f1-8fb9-4b9c-bc2b-837ec5dfc826
2026-09-28 22:39:18 +02:00

107 lines
4 KiB
Python

"""Derived wait qualifiers (CUST-WP-0074 rule 5) — read model only.
A ``wait`` task is *external* when it carries at least one dependency edge
(``workplan_dependencies.from_task_id``), *human* when ``needs_human`` is set,
*both* when both hold and *unqualified* otherwise. A ``blocked`` workplan is
*human* when any of its wait tasks is human or both, else *external* when any
is external, else *none*. Nothing here is stored; the routers call the
``annotate_*`` helpers and Pydantic picks the attributes up on serialisation.
"""
from __future__ import annotations
import uuid
from collections.abc import Iterable, Sequence
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.models.task import Task, TaskStatus
from api.models.workplan import Workplan
from api.models.workplan_dependency import WorkplanDependency
from api.task_status import normalize_task_status
from api.workplan_status import normalize_workplan_status
WAIT_KIND_EXTERNAL = "external"
WAIT_KIND_HUMAN = "human"
WAIT_KIND_BOTH = "both"
WAIT_KIND_UNQUALIFIED = "unqualified"
BLOCKED_KIND_EXTERNAL = "external"
BLOCKED_KIND_HUMAN = "human"
BLOCKED_KIND_NONE = "none"
def derive_wait_kind(status: Any, *, needs_human: bool, has_dependency: bool) -> str | None:
"""Rule 1 / rule 5 for one task. ``None`` unless the task is waiting."""
if normalize_task_status(status, default="todo") != "wait":
return None
if has_dependency and needs_human:
return WAIT_KIND_BOTH
if needs_human:
return WAIT_KIND_HUMAN
if has_dependency:
return WAIT_KIND_EXTERNAL
return WAIT_KIND_UNQUALIFIED
def derive_blocked_kind(status: Any, wait_kinds: Iterable[str | None]) -> str | None:
"""Rule 5 for one workplan. ``None`` unless the workplan is blocked."""
if normalize_workplan_status(status) != "blocked":
return None
kinds = {kind for kind in wait_kinds if kind}
if WAIT_KIND_HUMAN in kinds or WAIT_KIND_BOTH in kinds:
return BLOCKED_KIND_HUMAN
if WAIT_KIND_EXTERNAL in kinds:
return BLOCKED_KIND_EXTERNAL
return BLOCKED_KIND_NONE
async def task_ids_with_dependencies(
session: AsyncSession, task_ids: Sequence[uuid.UUID]
) -> set[uuid.UUID]:
if not task_ids:
return set()
rows = await session.execute(
select(WorkplanDependency.from_task_id).where(
WorkplanDependency.from_task_id.in_(list(task_ids))
)
)
return {row[0] for row in rows if row[0] is not None}
async def annotate_tasks(session: AsyncSession, tasks: Sequence[Task]) -> Sequence[Task]:
"""Set ``task.wait_kind`` on each ORM task (serialised by TaskRead)."""
waiting = [t for t in tasks if normalize_task_status(t.status, default="todo") == "wait"]
with_deps = await task_ids_with_dependencies(session, [t.id for t in waiting])
for task in tasks:
task.wait_kind = derive_wait_kind(
task.status,
needs_human=bool(task.needs_human),
has_dependency=task.id in with_deps,
)
return tasks
async def annotate_workplans(
session: AsyncSession, workplans: Sequence[Workplan]
) -> Sequence[Workplan]:
"""Set ``workplan.blocked_kind`` on each ORM workplan (serialised by WorkplanRead)."""
blocked = [
wp for wp in workplans if normalize_workplan_status(wp.status) == "blocked"
]
kinds_by_workplan: dict[uuid.UUID, list[str | None]] = {wp.id: [] for wp in blocked}
if blocked:
rows = await session.execute(
select(Task).where(
Task.workplan_id.in_(list(kinds_by_workplan)),
Task.status == TaskStatus.wait,
)
)
wait_tasks = list(rows.scalars().all())
await annotate_tasks(session, wait_tasks)
for task in wait_tasks:
kinds_by_workplan[task.workplan_id].append(task.wait_kind)
for wp in workplans:
wp.blocked_kind = derive_blocked_kind(wp.status, kinds_by_workplan.get(wp.id, []))
return workplans