diff --git a/api/services/forge_projection.py b/api/services/forge_projection.py index a0732f6..894573f 100644 --- a/api/services/forge_projection.py +++ b/api/services/forge_projection.py @@ -577,6 +577,9 @@ class ResetOutcome: refused: list[dict[str, Any]] = field(default_factory=list) # Identifiers freed from rows retired before retirement released them. released: list[str] = field(default_factory=list) + created_tasks: list[str] = field(default_factory=list) + updated_tasks: list[str] = field(default_factory=list) + cancelled_tasks: list[str] = field(default_factory=list) notes: list[str] = field(default_factory=list) def to_dict(self) -> dict[str, Any]: @@ -591,11 +594,17 @@ class ResetOutcome: "retired": len(self.retired), "refused": len(self.refused), "released": len(self.released), + "created_tasks": len(self.created_tasks), + "updated_tasks": len(self.updated_tasks), + "cancelled_tasks": len(self.cancelled_tasks), }, "created": self.created, "updated": self.updated, "retired": self.retired, "released": self.released, + "created_tasks": self.created_tasks, + "updated_tasks": self.updated_tasks, + "cancelled_tasks": self.cancelled_tasks, "refused": self.refused, "notes": self.notes, } @@ -626,6 +635,89 @@ def _tombstone_slug(slug: str, when: datetime) -> str: RETIRE_REASON = "no longer derived from the forge" +def _coerce_task_status(raw: str | None) -> Any: + from api.models.task import TaskStatus + + if not raw: + return None + try: + return TaskStatus(str(raw).strip().lower()) + except ValueError: + return None + + +def _task_status_value(status: Any) -> str: + return str(getattr(status, "value", status) or "") + + +def _sync_existing_workplan_tasks( + session: Any, + row: Any, + derived_wp: DerivedWorkplan, + hub_tasks: list[Any], + outcome: ResetOutcome, +) -> None: + """Match identified tasks of an existing workplan by record_id. + + Rows with no ``record_id`` predate the backfill and cannot be qualified + from the file alone (CUST-WP-0068-T09). They are not cancelled or + overwritten. + """ + from api.models.task import Task, TaskPriority, TaskStatus + + want = { + t.record_id.strip().lower(): t + for t in derived_wp.tasks + if t.record_id and t.record_id.strip() + } + matched: dict[str, Any] = {} + stale: list[Any] = [] + for ht in hub_tasks: + rid = (ht.record_id or "").strip().lower() + if not rid: + continue + if rid in want: + matched[rid] = ht + else: + stale.append(ht) + + for key, dt in want.items(): + ht = matched.get(key) + if ht is None: + kwargs: dict[str, Any] = { + "id": uuid.UUID(dt.uuid), + "workplan_id": row.id, + "record_id": dt.record_id, + "title": (dt.title or dt.record_id), + } + st = _coerce_task_status(dt.status) + if st is not None: + kwargs["status"] = st + if dt.priority: + try: + kwargs["priority"] = TaskPriority(dt.priority.strip().lower()) + except ValueError: + pass + session.add(Task(**kwargs)) + outcome.created_tasks.append(dt.record_id) + continue + changed = False + if dt.title and dt.title.strip() and ht.title != dt.title.strip(): + ht.title = dt.title.strip() + changed = True + st = _coerce_task_status(dt.status) + if st is not None and ht.status != st: + ht.status = st + changed = True + if changed: + outcome.updated_tasks.append(dt.record_id) + + for ht in stale: + if _task_status_value(ht.status) in {"wait", "todo", "progress"}: + ht.status = TaskStatus.cancel + outcome.cancelled_tasks.append(ht.record_id or str(ht.id)) + + async def reset_repository_projection( session: Any, repo_slug: str, @@ -646,10 +738,10 @@ async def reset_repository_projection( file was removed deliberately — or that someone pointed this at the wrong branch. The caller must say which. - Scope: workplans, and the tasks of workplans being created. Tasks of - *existing* workplans are left alone, because hub task rows carry no - canonical identifier and can only be matched by title — renaming a heading - would otherwise destroy and recreate its record (`T06`). + Scope: workplans, and their tasks. Tasks of existing workplans are matched + by ``record_id`` (STATE-WP-0083-T06); rows with no ``record_id`` are left + alone. A derived task the hub lacks is created; an identified hub task the + forge no longer derives is cancelled if it is still open. """ from datetime import datetime, timezone @@ -932,6 +1024,17 @@ async def reset_repository_projection( now = datetime.now(tz=timezone.utc) + existing_ids = [matched[k].id for k, _w in want.items() if k in matched] + tasks_by_wp: dict[Any, list[Any]] = {} + if existing_ids: + loaded = list( + ( + await session.execute(select(Task).where(Task.workplan_id.in_(existing_ids))) + ).scalars() + ) + for task_row in loaded: + tasks_by_wp.setdefault(task_row.workplan_id, []).append(task_row) + for key, w in want.items(): row = matched.get(key) if row is None: @@ -995,6 +1098,9 @@ async def reset_repository_projection( changed = True if changed: outcome.updated.append(w.record_id) + _sync_existing_workplan_tasks( + session, row, w, tasks_by_wp.get(row.id, []), outcome + ) for r in stale: outcome.retired.append(r.slug or str(r.id)) @@ -1002,12 +1108,16 @@ async def reset_repository_projection( r.projection_retired_reason = RETIRE_REASON r.slug = _tombstone_slug(r.slug or str(r.id), now) - if outcome.created or outcome.updated or outcome.retired or outcome.released: + if ( + outcome.created + or outcome.updated + or outcome.retired + or outcome.released + or outcome.created_tasks + or outcome.updated_tasks + or outcome.cancelled_tasks + ): outcome.status = "applied" - outcome.notes.append( - "Tasks of existing workplans were not touched; hub tasks carry no " - "canonical identifier (STATE-WP-0083-T06)." - ) return outcome diff --git a/tests/test_forge_projection.py b/tests/test_forge_projection.py index fa8dbc9..c8b854a 100644 --- a/tests/test_forge_projection.py +++ b/tests/test_forge_projection.py @@ -218,11 +218,12 @@ class _Row: class _FakeSession: """Stands in for AsyncSession: enough to prove intent without a database.""" - def __init__(self, repo, rows, foreign=None, slug_clash=None): + def __init__(self, repo, rows, foreign=None, slug_clash=None, task_rows=None): self._repo = repo self.rows = list(rows) self._foreign = list(foreign or []) self._slug_clash = list(slug_clash or []) + self._task_rows = task_rows self.added = [] self.deleted = [] self.committed = False @@ -231,16 +232,24 @@ class _FakeSession: async def execute(self, *_a, **_k): self._calls += 1 repo, rows = self._repo, self.rows - # 1st call resolves the repo, 2nd loads its workplans, 3rd is the - # foreign-identifier lookup. # 1 resolves the repo, 2 loads its workplans, 3 is the identifier - # lookup, 4 the slug lookup. + # lookup, 4 the slug lookup. On the update-only path (no creates) + # the 3rd call is the existing-task load instead. + update_only = ( + self._task_rows is not None + and not self._foreign + and not self._slug_clash + ) if self._calls == 2: payload = rows + elif update_only and self._calls == 3: + payload = self._task_rows elif self._calls == 3: payload = self._foreign elif self._calls == 4: payload = self._slug_clash + elif self._calls == 5: + payload = self._task_rows or [] else: payload = [] @@ -1030,3 +1039,81 @@ class TestTitleSyncBehaviour: session = _FakeSession(repo=_Repo(), rows=[row]) await fp.reset_repository_projection(session, "demo", derived=d) assert row.title == "Real Title" + + +class _TaskRow: + def __init__(self, record_id, status="todo", title="t", workplan_id=None): + import uuid as _u + self.id = _u.uuid4() + self.record_id = record_id + self.status = status + self.title = title + self.priority = "medium" + self.workplan_id = workplan_id + + +class TestExistingWorkplanTasks: + """Tasks of an existing workplan are matched by record_id (CUST-WP-0068-T09).""" + + def _derived(self, *tasks): + dts = [ + fp.DerivedTask( + record_id=tid, + uuid=fp.derived_record_uuid(tid), + title=title, + status=st, + priority="medium", + ) + for tid, title, st in tasks + ] + return fp.DerivedProjection( + repo_slug="demo", + commit="c0ffee", + workplans=[ + fp.DerivedWorkplan( + record_id="DEMO-WP-0001", + uuid=fp.derived_record_uuid("DEMO-WP-0001"), + title="DEMO-WP-0001", + status="active", + relative_path="workplans/a.md", + archived=False, + tasks=dts, + ) + ], + ) + + @pytest.mark.asyncio + async def test_creates_a_missing_identified_task(self): + row = _Row(slug="demo-wp-0001", status="active", path="workplans/a.md") + session = _FakeSession(repo=_Repo(), rows=[row], task_rows=[]) + out = await fp.reset_repository_projection( + session, "demo", + derived=self._derived(("DEMO-WP-0001-T01", "Do it", "todo")), + ) + assert out.created_tasks == ["DEMO-WP-0001-T01"] + assert session.added + assert session.added[0].record_id == "DEMO-WP-0001-T01" + + @pytest.mark.asyncio + async def test_cancels_an_open_task_the_file_no_longer_derives(self): + row = _Row(slug="demo-wp-0001", status="active", path="workplans/a.md") + stale = _TaskRow("DEMO-WP-0001-T09", status="todo", workplan_id=row.id) + session = _FakeSession(repo=_Repo(), rows=[row], task_rows=[stale]) + out = await fp.reset_repository_projection( + session, "demo", + derived=self._derived(("DEMO-WP-0001-T01", "Do it", "todo")), + ) + assert stale.status.value == "cancel" or stale.status == "cancel" or str(stale.status).endswith("cancel") + assert "DEMO-WP-0001-T09" in out.cancelled_tasks + assert session.deleted == [] + + @pytest.mark.asyncio + async def test_leaves_unidentified_tasks_alone(self): + row = _Row(slug="demo-wp-0001", status="active", path="workplans/a.md") + orphan = _TaskRow(None, status="todo", title="legacy", workplan_id=row.id) + session = _FakeSession(repo=_Repo(), rows=[row], task_rows=[orphan]) + await fp.reset_repository_projection( + session, "demo", + derived=self._derived(("DEMO-WP-0001-T01", "Do it", "todo")), + ) + assert orphan.status == "todo"