From a495a27e0ecec75c0d12bc058ba9afeed1ddba58 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 29 Sep 2026 21:38:27 +0200 Subject: [PATCH] CUST-WP-0074-T02 follow-up: blocked_on / wait_until reach the hub read model Migration f9a0b1c2d3e4 (revises e8f9a0b1c2d3) adds tasks.blocked_on (String 200) and tasks.wait_until (Date); TaskCreate/Update/Read carry both. Read model (api/services/wait_kind.py): a wait task is external when it has a dependency edge, a blocked_on matching ^message-from: (rule 6), or a wait_until date (a window clears by itself). human/both/unqualified and blocked_kind are unchanged in shape. fix-consistency: _task_wait_fields carries blocked_on and wait_until (ISO date, PyYAML date objects normalised), so the C-06/C-11 creates and the C-10 status PATCH send them, and C-41 compares and syncs them like the other qualifiers. C-39 accepts wait_until: YYYY-MM-DD as a qualifier; C-40 also fires when wait_until is before UTC today (report only). /state/deps: depends_on stubs gain from_task_id / from_task_title so consumers see task-scoped edges; both optional, response otherwise as before. Co-Authored-By: Claude Fable 5.1 Assistant: claude-code Assistant-Model: sonnet Assistant-Process: 237582@bnt-lap001 Assistant-Session: f2b3d9f1-8fb9-4b9c-bc2b-837ec5dfc826 --- api/models/task.py | 4 + api/routers/state.py | 12 +++ api/schemas/task.py | 6 ++ api/schemas/workplan_dependency.py | 4 + api/services/wait_kind.py | 41 +++++++-- .../versions/f9a0b1c2d3e4_task_wait_window.py | 27 ++++++ scripts/consistency_check.py | 70 ++++++++++++--- tests/test_consistency_check.py | 71 +++++++++++++++ tests/test_wait_kind.py | 90 +++++++++++++++++++ 9 files changed, 306 insertions(+), 19 deletions(-) create mode 100644 migrations/versions/f9a0b1c2d3e4_task_wait_window.py diff --git a/api/models/task.py b/api/models/task.py index 8cc50da..389586b 100644 --- a/api/models/task.py +++ b/api/models/task.py @@ -60,6 +60,10 @@ class Task(Base, TimestampMixin): # "CCR-2026-0004" (CUST-WP-0074 Case B). Free text: the decision may live # in another repo and may not be indexed in the hub yet. decision_id: Mapped[str | None] = mapped_column(String(120), nullable=True) + # Owner-return marker "message-from:" (CUST-WP-0074 rule 6) and a + # date after which the wait clears by itself; either counts as external. + blocked_on: Mapped[str | None] = mapped_column(String(200), nullable=True) + wait_until: Mapped[date | None] = mapped_column(Date, nullable=True) parent_task_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL", onupdate="CASCADE"), diff --git a/api/routers/state.py b/api/routers/state.py index 1545449..05c9000 100644 --- a/api/routers/state.py +++ b/api/routers/state.py @@ -909,12 +909,15 @@ async def get_deps( dep_ws_ids: set = set() dep_task_ids: set = set() + open_ws_id_set = set(open_ws_ids) for d in dep_rows: dep_ws_ids.add(d.from_workplan_id) if d.to_workplan_id: dep_ws_ids.add(d.to_workplan_id) if d.to_task_id: dep_task_ids.add(d.to_task_id) + if d.from_task_id and d.from_workplan_id in open_ws_id_set: + dep_task_ids.add(d.from_task_id) ws_lookup: dict = {w.id: w for w in open_ws} extra_ids = dep_ws_ids - set(ws_lookup.keys()) @@ -933,16 +936,25 @@ async def get_deps( dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws} for d in dep_rows: from_id, to_id, task_id = d.from_workplan_id, d.to_workplan_id, d.to_task_id + # Task-scoped edge (CUST-WP-0074): the waiting task on the from side, + # only surfaced on the workplan's own depends_on stubs. + from_task = task_lookup.get(d.from_task_id) if d.from_task_id else None + from_task_fields = { + "from_task_id": d.from_task_id, + "from_task_title": from_task.title if from_task is not None else None, + } if from_id in dep_index and to_id and to_id in ws_lookup: dep_index[from_id]["depends_on"].append(WorkstreamDepStub( dep_id=d.id, target_type="workstream", relationship_type=d.relationship_type, workstream_id=to_id, workstream_slug=ws_lookup[to_id].slug, workstream_title=ws_lookup[to_id].title, description=d.description, + **from_task_fields, )) if from_id in dep_index and task_id and task_id in task_lookup: dep_index[from_id]["depends_on"].append(WorkstreamDepStub( dep_id=d.id, target_type="task", relationship_type=d.relationship_type, task_id=task_id, task_title=task_lookup[task_id].title, description=d.description, + **from_task_fields, )) if to_id and to_id in dep_index and from_id in ws_lookup: dep_index[to_id]["blocks"].append(WorkstreamDepStub( diff --git a/api/schemas/task.py b/api/schemas/task.py index ae1bfe6..a32b58e 100644 --- a/api/schemas/task.py +++ b/api/schemas/task.py @@ -46,6 +46,8 @@ class TaskCreate(TaskStatusMixin, TaskFlavorMixin, WorkplanIdCreateMixin): needs_human: bool = False intervention_note: str | None = None decision_id: str | None = None + blocked_on: str | None = None + wait_until: date | None = None parent_task_id: uuid.UUID | None = None @model_validator(mode="after") @@ -67,6 +69,8 @@ class TaskUpdate(TaskStatusMixin, TaskFlavorMixin): needs_human: bool | None = None intervention_note: str | None = None decision_id: str | None = None + blocked_on: str | None = None + wait_until: date | None = None parent_task_id: uuid.UUID | None = None # Token passthrough — three tiers (highest precision wins): # 1. tokens_in + tokens_out → exact counts; note defaults to "measured" @@ -131,6 +135,8 @@ class TaskRead(TaskStatusMixin, WorkplanIdCompatMixin): needs_human: bool intervention_note: str | None = None decision_id: str | None = None + blocked_on: str | None = None + wait_until: date | None = None parent_task_id: uuid.UUID | None = None # Derived (CUST-WP-0074 rule 5): "external" | "human" | "both" | # "unqualified" while status is wait, else None. Set by diff --git a/api/schemas/workplan_dependency.py b/api/schemas/workplan_dependency.py index effb601..31ec765 100644 --- a/api/schemas/workplan_dependency.py +++ b/api/schemas/workplan_dependency.py @@ -47,6 +47,10 @@ class WorkplanDepStub(BaseModel): ) task_id: uuid.UUID | None = None task_title: str | None = None + # The waiting task on a task-scoped edge (workplan_dependencies.from_task_id); + # None on frontmatter edges. + from_task_id: uuid.UUID | None = None + from_task_title: str | None = None description: str | None = None @computed_field # type: ignore[prop-decorator] diff --git a/api/services/wait_kind.py b/api/services/wait_kind.py index 3994a75..28ab11a 100644 --- a/api/services/wait_kind.py +++ b/api/services/wait_kind.py @@ -1,16 +1,20 @@ """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 +(``workplan_dependencies.from_task_id``), an owner-return marker +(``blocked_on: message-from:``, rule 6) or a ``wait_until`` date (a +time window clears by itself); *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 re import uuid from collections.abc import Iterable, Sequence +from datetime import date from typing import Any from sqlalchemy import select @@ -31,16 +35,41 @@ BLOCKED_KIND_EXTERNAL = "external" BLOCKED_KIND_HUMAN = "human" BLOCKED_KIND_NONE = "none" +# Rule 6 owner-return marker; mirrors _BLOCKED_ON_RE in scripts/consistency_check.py. +MESSAGE_FROM_RE = re.compile(r"^message-from:.+$") -def derive_wait_kind(status: Any, *, needs_human: bool, has_dependency: bool) -> str | None: + +def is_external_wait( + *, + has_dependency: bool, + blocked_on: str | None = None, + wait_until: date | None = None, +) -> bool: + """Case A holds for a dependency edge, an owner-return marker or a wait window.""" + if has_dependency or wait_until is not None: + return True + return bool(blocked_on and MESSAGE_FROM_RE.match(blocked_on.strip())) + + +def derive_wait_kind( + status: Any, + *, + needs_human: bool, + has_dependency: bool, + blocked_on: str | None = None, + wait_until: date | None = None, +) -> 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: + external = is_external_wait( + has_dependency=has_dependency, blocked_on=blocked_on, wait_until=wait_until + ) + if external and needs_human: return WAIT_KIND_BOTH if needs_human: return WAIT_KIND_HUMAN - if has_dependency: + if external: return WAIT_KIND_EXTERNAL return WAIT_KIND_UNQUALIFIED @@ -79,6 +108,8 @@ async def annotate_tasks(session: AsyncSession, tasks: Sequence[Task]) -> Sequen task.status, needs_human=bool(task.needs_human), has_dependency=task.id in with_deps, + blocked_on=task.blocked_on, + wait_until=task.wait_until, ) return tasks diff --git a/migrations/versions/f9a0b1c2d3e4_task_wait_window.py b/migrations/versions/f9a0b1c2d3e4_task_wait_window.py new file mode 100644 index 0000000..6b6fcce --- /dev/null +++ b/migrations/versions/f9a0b1c2d3e4_task_wait_window.py @@ -0,0 +1,27 @@ +"""task wait window and owner-return marker (CUST-WP-0074-T02 follow-up) + +Adds ``tasks.blocked_on`` (the task-block ``blocked_on: message-from:`` +owner-return marker, rule 6) and ``tasks.wait_until`` (a date after which a +wait clears by itself) so that both qualifiers reach the hub read model +instead of living only in the workplan file. + +Revision ID: f9a0b1c2d3e4 +Revises: e8f9a0b1c2d3 +""" +import sqlalchemy as sa +from alembic import op + +revision = "f9a0b1c2d3e4" +down_revision = "e8f9a0b1c2d3" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("tasks", sa.Column("blocked_on", sa.String(length=200), nullable=True)) + op.add_column("tasks", sa.Column("wait_until", sa.Date(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("tasks", "wait_until") + op.drop_column("tasks", "blocked_on") diff --git a/scripts/consistency_check.py b/scripts/consistency_check.py index 7bcb42e..f41471d 100644 --- a/scripts/consistency_check.py +++ b/scripts/consistency_check.py @@ -42,9 +42,9 @@ Checks: C-36 work-record-flavor-unknown WARN No flavor is set but not in the closed list C-37 residual-provenance-missing WARN No flavor: residual without origin/origin_ref C-38 task-flavor-drift WARN Yes task flavor differs between file and DB (file wins) - C-39 task-wait-unqualified WARN No wait task has neither task-block depends_on nor needs_human: true (CUST-WP-0074 rule 1) - C-40 task-wait-blocker-satisfied WARN No wait task whose depends_on targets are all terminal, or whose decision_id is resolved (CUST-WP-0074 rules 3/4) - C-41 task-wait-qualifier-drift WARN Yes needs_human/blocking_reason/decision_id differ between file and DB (file wins) + C-39 task-wait-unqualified WARN No wait task has none of task-block depends_on, needs_human: true, blocked_on: message-from:, wait_until: YYYY-MM-DD (CUST-WP-0074 rules 1/6) + C-40 task-wait-blocker-satisfied WARN No wait task whose depends_on targets are all terminal, whose decision_id is resolved, or whose wait_until has passed (CUST-WP-0074 rules 3/4) + C-41 task-wait-qualifier-drift WARN Yes needs_human/blocking_reason/decision_id/blocked_on/wait_until differ between file and DB (file wins) (C-20 also indexes task-block depends_on as workplan_dependencies rows with from_task_id set) (finished¬DoD-Ok is listed by `statehub quality-debt`, not per-file C-warn — avoids historical flood) @@ -90,7 +90,7 @@ import uuid from contextlib import contextmanager from dataclasses import dataclass, field from collections import Counter -from datetime import datetime, timedelta, timezone +from datetime import date as _date, datetime, timedelta, timezone from pathlib import Path from typing import Any @@ -181,6 +181,7 @@ _WP_FILE_BARE_RE = re.compile(r"^(WP)-\d+", re.IGNORECASE) _WP_ID_PREFIX_RE = re.compile(r"^([A-Z][A-Z0-9-]*-WP)-\d+", re.IGNORECASE) _WP_ID_BARE_RE = re.compile(r"^(WP)-\d+", re.IGNORECASE) _BLOCKED_ON_RE = re.compile(r"^message-from:(?P.+)$") +_WAIT_UNTIL_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") _WORK_REQUEST_RE = re.compile( r"\b(implement|workplan|multi-?step|please\s+(implement|add|create|fix|build))\b", re.IGNORECASE, @@ -562,18 +563,41 @@ def _as_bool(value: Any) -> bool: return str(value).strip().lower() in {"true", "yes", "1", "on"} +def _task_wait_until(task: dict) -> str | None: + """``wait_until`` as an ISO date string, or None when absent or not a + ``YYYY-MM-DD`` date (PyYAML hands a bare date over as ``datetime.date``).""" + raw = task.get("wait_until") + if raw in (None, ""): + return None + if isinstance(raw, datetime): + raw = raw.date() + if isinstance(raw, _date): + return raw.isoformat() + text = str(raw).strip().strip('"').strip("'") + if not _WAIT_UNTIL_RE.match(text): + return None + try: + return _date.fromisoformat(text).isoformat() + except ValueError: + return None + + def _task_wait_fields(task: dict) -> dict[str, Any]: """File → hub fields that qualify a wait (CUST-WP-0074): needs_human, - blocking_reason, decision_id. TaskCreate/TaskUpdate require an - intervention_note whenever needs_human is set; the blocking reason is by - rule 2 what the human must do, so it stands in when the block has none.""" + blocking_reason, decision_id, blocked_on, wait_until. TaskCreate/TaskUpdate + require an intervention_note whenever needs_human is set; the blocking + reason is by rule 2 what the human must do, so it stands in when the block + has none.""" needs_human = _as_bool(task.get("needs_human")) blocking_reason = str(task.get("blocking_reason") or "").strip() or None decision_id = str(task.get("decision_id") or "").strip().strip('"') or None + blocked_on = str(task.get("blocked_on") or "").strip().strip('"') or None fields: dict[str, Any] = { "needs_human": needs_human, "blocking_reason": blocking_reason, "decision_id": decision_id, + "blocked_on": blocked_on, + "wait_until": _task_wait_until(task), } if needs_human: fields["intervention_note"] = ( @@ -582,7 +606,7 @@ def _task_wait_fields(task: dict) -> dict[str, Any]: return fields -_TASK_WAIT_FIELD_KEYS = ("needs_human", "blocking_reason", "decision_id") +_TASK_WAIT_FIELD_KEYS = ("needs_human", "blocking_reason", "decision_id", "blocked_on", "wait_until") def _dependency_target_satisfied(record_id: str, status: str) -> bool: @@ -2018,21 +2042,37 @@ def check_repo( wait_fields = _task_wait_fields(task) if t_status == "wait" and normalised_file_status not in CLOSED_WORKSTREAM_STATUSES: # Rule 6: an owner-return wait with no workplan id yet is - # qualified by the C-25 vocabulary on the task block. - owner_return = bool( - _BLOCKED_ON_RE.match(str(task.get("blocked_on") or "").strip()) - ) - if not task_dep_targets and not wait_fields["needs_human"] and not owner_return: + # qualified by the C-25 vocabulary on the task block; a + # wait_until date is a window that clears by itself. + owner_return = bool(_BLOCKED_ON_RE.match(wait_fields["blocked_on"] or "")) + wait_until = wait_fields["wait_until"] + if ( + not task_dep_targets and not wait_fields["needs_human"] + and not owner_return and not wait_until + ): report.add( severity="WARN", check_id="C-39", message=( f"Wait task '{t_id}' in workplan '{meta.get('id', fname)}' has " - f"neither depends_on nor needs_human: true — unqualified wait" + f"neither depends_on, needs_human: true, blocked_on: message-from:… " + f"nor wait_until — unqualified wait" ), file_path=f"{fname}#{t_id}", file_value=t_status, fixable=False, ) + # C-40: rule 3 for a wait window — the date has passed (UTC today). + if wait_until and _date.fromisoformat(wait_until) < datetime.now(timezone.utc).date(): + report.add( + severity="WARN", check_id="C-40", + message=( + f"blocker satisfied, task still wait: '{t_id}' in workplan " + f"'{meta.get('id', fname)}' — wait_until {wait_until} has passed" + ), + file_path=f"{fname}#{t_id}", + file_value=wait_until, + fixable=False, + ) # C-40: rule 3 — every depends_on target terminal. A target the # hub cannot resolve is skipped, which also withholds the warning. resolved_targets = [ @@ -2251,6 +2291,8 @@ def check_repo( "needs_human": bool(db_task.get("needs_human")), "blocking_reason": str(db_task.get("blocking_reason") or "").strip() or None, "decision_id": str(db_task.get("decision_id") or "").strip() or None, + "blocked_on": str(db_task.get("blocked_on") or "").strip() or None, + "wait_until": str(db_task.get("wait_until") or "").strip() or None, } file_wait_fields = {key: wait_fields[key] for key in _TASK_WAIT_FIELD_KEYS} if file_wait_fields != db_wait_fields: diff --git a/tests/test_consistency_check.py b/tests/test_consistency_check.py index e5e8ba4..06c49f2 100644 --- a/tests/test_consistency_check.py +++ b/tests/test_consistency_check.py @@ -2364,6 +2364,8 @@ class TestTaskWaitQualifiers: "needs_human": True, "blocking_reason": "operator must provision creds", "decision_id": "CCR-2026-0004", + "blocked_on": None, + "wait_until": None, "intervention_note": "operator must provision creds", }, } @@ -2373,6 +2375,73 @@ class TestTaskWaitQualifiers: self._install(monkeypatch, self._fake_get(repo, db_task=db_task)) assert self._issues(check_repo("http://unused", "demo-repo"), "C-41") == [] + def test_c41_syncs_blocked_on_and_wait_until(self, tmp_path, monkeypatch): + repo = self._make_repo( + tmp_path, + "id: STATE-WP-0001-T01\nstatus: wait\npriority: high\n" + "state_hub_task_id: \"task-1\"\nblocked_on: message-from:fin-hub\n" + "wait_until: 2099-01-31\nblocking_reason: \"fin-hub owner returns an id\"", + ) + db_task = {"id": "task-1", "status": "wait", "needs_human": False, + "blocking_reason": "fin-hub owner returns an id", "decision_id": None, + "blocked_on": None, "wait_until": None} + self._install(monkeypatch, self._fake_get(repo, db_task=db_task)) + + report = check_repo("http://unused", "demo-repo") + + c41 = self._issues(report, "C-41") + assert len(c41) == 1 and c41[0].fixable is True + assert c41[0]._fix_context["wait_fields"] == { + "needs_human": False, + "blocking_reason": "fin-hub owner returns an id", + "decision_id": None, + "blocked_on": "message-from:fin-hub", + "wait_until": "2099-01-31", + } + + # The hub echoes the date as ISO text; in sync → silent. + db_task.update(blocked_on="message-from:fin-hub", wait_until="2099-01-31") + self._install(monkeypatch, self._fake_get(repo, db_task=db_task)) + assert self._issues(check_repo("http://unused", "demo-repo"), "C-41") == [] + + def test_wait_until_qualifies_c39_and_past_date_warns_c40(self, tmp_path, monkeypatch): + repo = self._make_repo( + tmp_path, + "id: STATE-WP-0001-T01\nstatus: wait\npriority: high\n" + "state_hub_task_id: \"task-1\"\nwait_until: 2099-01-31\n" + "blocking_reason: \"embargo until the window opens\"", + ) + self._install(monkeypatch, self._fake_get(repo)) + report = check_repo("http://unused", "demo-repo") + assert self._issues(report, "C-39") == [] + assert self._issues(report, "C-40") == [] + + repo = self._make_repo( + tmp_path / "past", + "id: STATE-WP-0001-T01\nstatus: wait\npriority: high\n" + "state_hub_task_id: \"task-1\"\nwait_until: \"2020-01-31\"\n" + "blocking_reason: \"embargo until the window opens\"", + ) + self._install(monkeypatch, self._fake_get(repo)) + report = check_repo("http://unused", "demo-repo") + assert self._issues(report, "C-39") == [] + c40 = self._issues(report, "C-40") + assert len(c40) == 1 and c40[0].fixable is False + assert c40[0].message.startswith("blocker satisfied, task still wait: 'STATE-WP-0001-T01'") + assert "wait_until 2020-01-31 has passed" in c40[0].message + + # Not a YYYY-MM-DD date → no qualifier, C-39 as before. + repo = self._make_repo( + tmp_path / "junk", + "id: STATE-WP-0001-T01\nstatus: wait\npriority: high\n" + "state_hub_task_id: \"task-1\"\nwait_until: soon\n" + "blocking_reason: \"embargo until the window opens\"", + ) + self._install(monkeypatch, self._fake_get(repo)) + report = check_repo("http://unused", "demo-repo") + assert len(self._issues(report, "C-39")) == 1 + assert self._issues(report, "C-40") == [] + def test_c41_ignores_blocks_that_do_not_carry_the_keys(self, tmp_path, monkeypatch): repo = self._make_repo( tmp_path, @@ -2431,6 +2500,8 @@ class TestTaskWaitQualifiers: "needs_human": False, "blocking_reason": "access-engine rename must land first", "decision_id": None, + "blocked_on": None, + "wait_until": None, } assert ("/tasks/task-1", {"status": "wait", "suppress_token_event": True, **wait_fields}) in patches assert ("/tasks/task-1", wait_fields) in patches diff --git a/tests/test_wait_kind.py b/tests/test_wait_kind.py index 64b2475..0a96ff1 100644 --- a/tests/test_wait_kind.py +++ b/tests/test_wait_kind.py @@ -2,6 +2,8 @@ from_task_id dependency edge. Real PostgreSQL via the shared `client` fixture.""" from __future__ import annotations +from datetime import date + import pytest from api.services.wait_kind import derive_blocked_kind, derive_wait_kind @@ -16,6 +18,23 @@ class TestDeriveFunctions: assert derive_wait_kind("wait", needs_human=True, has_dependency=False) == "human" assert derive_wait_kind("wait", needs_human=True, has_dependency=True) == "both" + def test_owner_return_marker_and_wait_window_are_external(self): + assert derive_wait_kind( + "wait", needs_human=False, has_dependency=False, blocked_on="message-from:fin-hub" + ) == "external" + assert derive_wait_kind( + "wait", needs_human=False, has_dependency=False, blocked_on="fin-hub" + ) == "unqualified" + assert derive_wait_kind( + "wait", needs_human=False, has_dependency=False, wait_until=date(2099, 1, 31) + ) == "external" + assert derive_wait_kind( + "wait", needs_human=True, has_dependency=False, wait_until=date(2020, 1, 31) + ) == "both" + assert derive_wait_kind( + "todo", needs_human=False, has_dependency=False, blocked_on="message-from:x" + ) is None + def test_blocked_kind_prefers_human(self): assert derive_blocked_kind("active", ["human"]) is None assert derive_blocked_kind("blocked", []) == "none" @@ -124,3 +143,74 @@ async def test_two_tasks_may_wait_on_the_same_target(client): assert r.status_code == 201, r.text r = await client.get(f"/workplans/{wp['id']}/dependencies/") assert sorted(row["from_task_id"] for row in r.json()) == sorted([t1["id"], t2["id"]]) + + +@pytest.mark.asyncio +async def test_blocked_on_and_wait_until_reach_the_read_model(client): + domain = await create_test_domain(client) + repo = await create_test_repo(client, domain_slug=domain["slug"]) + blocked = await create_test_workplan(client, repo["id"], slug="blocked", status="blocked") + + owner_return = await _task( + client, blocked["id"], "owner return", status="wait", + blocking_reason="fin-hub owner returns an id", blocked_on="message-from:fin-hub", + ) + window = await _task( + client, blocked["id"], "window", status="wait", + blocking_reason="embargo", wait_until="2099-01-31", + ) + plain = await _task( + client, blocked["id"], "plain", status="wait", blocking_reason="?", blocked_on="fin-hub", + ) + + r = await client.get("/tasks/", params={"workplan_id": blocked["id"]}) + rows = {row["title"]: row for row in r.json()} + assert rows["owner return"]["wait_kind"] == "external" + assert rows["owner return"]["blocked_on"] == "message-from:fin-hub" + assert rows["window"]["wait_kind"] == "external" + assert rows["window"]["wait_until"] == "2099-01-31" + assert rows["plain"]["wait_kind"] == "unqualified" + r = await client.get(f"/workplans/{blocked['id']}") + assert r.json()["blocked_kind"] == "external" + + # Clearing the window on PATCH drops the qualifier again. + r = await client.patch(f"/tasks/{window['id']}", json={"wait_until": None}) + assert r.status_code == 200, r.text + assert r.json()["wait_until"] is None + r = await client.get(f"/tasks/{window['id']}") + assert r.json()["wait_kind"] == "unqualified" + del owner_return, plain + + +@pytest.mark.asyncio +async def test_state_deps_stubs_carry_the_waiting_task(client): + domain = await create_test_domain(client) + repo = await create_test_repo(client, domain_slug=domain["slug"]) + upstream = await create_test_workplan(client, repo["id"], slug="upstream", status="active") + waiting = await create_test_workplan(client, repo["id"], slug="waiting", status="blocked") + upstream_task = await _task(client, upstream["id"], "upstream task") + waiter = await _task(client, waiting["id"], "waiter", status="wait", blocking_reason="upstream") + + for body in ( + {"from_task_id": waiter["id"], "to_workplan_id": upstream["id"]}, + {"from_task_id": waiter["id"], "to_task_id": upstream_task["id"]}, + {"to_workplan_id": upstream["id"], "relationship_type": "related"}, + ): + r = await client.post(f"/workplans/{waiting['id']}/dependencies/", json=body) + assert r.status_code == 201, r.text + + deps = {row["id"]: row for row in (await client.get("/state/deps")).json()} + stubs = { + (stub["target_type"], stub["relationship_type"]): stub + for stub in deps[waiting["id"]]["depends_on"] + } + assert stubs[("workstream", "blocks")]["from_task_id"] == waiter["id"] + assert stubs[("workstream", "blocks")]["from_task_title"] == "waiter" + assert stubs[("task", "blocks")]["from_task_id"] == waiter["id"] + assert stubs[("task", "blocks")]["task_id"] == upstream_task["id"] + assert stubs[("workstream", "related")]["from_task_id"] is None + assert stubs[("workstream", "related")]["from_task_title"] is None + # The reverse side is unchanged: blocks stubs name the workplan only. + for stub in deps[upstream["id"]]["blocks"]: + assert stub["workstream_id"] == waiting["id"] + assert stub["from_task_id"] is None