sync identified tasks of existing workplans on projection reset
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s

Hub tasks now carry record_id, so reset_repository_projection can create
missing derived tasks, update title/status, and cancel open tasks the
forge no longer derives. Rows with no record_id are left alone.

Assistant: grok
Assistant-Session: 01a04996-76e8-7f53-b971-1885cfbed436
This commit is contained in:
tegwick 2026-08-28 21:06:24 +02:00
parent bb1b8f4c95
commit 3204baf8fd
2 changed files with 210 additions and 13 deletions

View file

@ -577,6 +577,9 @@ class ResetOutcome:
refused: list[dict[str, Any]] = field(default_factory=list) refused: list[dict[str, Any]] = field(default_factory=list)
# Identifiers freed from rows retired before retirement released them. # Identifiers freed from rows retired before retirement released them.
released: list[str] = field(default_factory=list) 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) notes: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
@ -591,11 +594,17 @@ class ResetOutcome:
"retired": len(self.retired), "retired": len(self.retired),
"refused": len(self.refused), "refused": len(self.refused),
"released": len(self.released), "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, "created": self.created,
"updated": self.updated, "updated": self.updated,
"retired": self.retired, "retired": self.retired,
"released": self.released, "released": self.released,
"created_tasks": self.created_tasks,
"updated_tasks": self.updated_tasks,
"cancelled_tasks": self.cancelled_tasks,
"refused": self.refused, "refused": self.refused,
"notes": self.notes, "notes": self.notes,
} }
@ -626,6 +635,89 @@ def _tombstone_slug(slug: str, when: datetime) -> str:
RETIRE_REASON = "no longer derived from the forge" 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( async def reset_repository_projection(
session: Any, session: Any,
repo_slug: str, repo_slug: str,
@ -646,10 +738,10 @@ async def reset_repository_projection(
file was removed deliberately or that someone pointed this at the wrong file was removed deliberately or that someone pointed this at the wrong
branch. The caller must say which. branch. The caller must say which.
Scope: workplans, and the tasks of workplans being created. Tasks of Scope: workplans, and their tasks. Tasks of existing workplans are matched
*existing* workplans are left alone, because hub task rows carry no by ``record_id`` (STATE-WP-0083-T06); rows with no ``record_id`` are left
canonical identifier and can only be matched by title renaming a heading alone. A derived task the hub lacks is created; an identified hub task the
would otherwise destroy and recreate its record (`T06`). forge no longer derives is cancelled if it is still open.
""" """
from datetime import datetime, timezone from datetime import datetime, timezone
@ -932,6 +1024,17 @@ async def reset_repository_projection(
now = datetime.now(tz=timezone.utc) 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(): for key, w in want.items():
row = matched.get(key) row = matched.get(key)
if row is None: if row is None:
@ -995,6 +1098,9 @@ async def reset_repository_projection(
changed = True changed = True
if changed: if changed:
outcome.updated.append(w.record_id) outcome.updated.append(w.record_id)
_sync_existing_workplan_tasks(
session, row, w, tasks_by_wp.get(row.id, []), outcome
)
for r in stale: for r in stale:
outcome.retired.append(r.slug or str(r.id)) 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.projection_retired_reason = RETIRE_REASON
r.slug = _tombstone_slug(r.slug or str(r.id), now) 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.status = "applied"
outcome.notes.append(
"Tasks of existing workplans were not touched; hub tasks carry no "
"canonical identifier (STATE-WP-0083-T06)."
)
return outcome return outcome

View file

@ -218,11 +218,12 @@ class _Row:
class _FakeSession: class _FakeSession:
"""Stands in for AsyncSession: enough to prove intent without a database.""" """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._repo = repo
self.rows = list(rows) self.rows = list(rows)
self._foreign = list(foreign or []) self._foreign = list(foreign or [])
self._slug_clash = list(slug_clash or []) self._slug_clash = list(slug_clash or [])
self._task_rows = task_rows
self.added = [] self.added = []
self.deleted = [] self.deleted = []
self.committed = False self.committed = False
@ -231,16 +232,24 @@ class _FakeSession:
async def execute(self, *_a, **_k): async def execute(self, *_a, **_k):
self._calls += 1 self._calls += 1
repo, rows = self._repo, self.rows 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 # 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: if self._calls == 2:
payload = rows payload = rows
elif update_only and self._calls == 3:
payload = self._task_rows
elif self._calls == 3: elif self._calls == 3:
payload = self._foreign payload = self._foreign
elif self._calls == 4: elif self._calls == 4:
payload = self._slug_clash payload = self._slug_clash
elif self._calls == 5:
payload = self._task_rows or []
else: else:
payload = [] payload = []
@ -1030,3 +1039,81 @@ class TestTitleSyncBehaviour:
session = _FakeSession(repo=_Repo(), rows=[row]) session = _FakeSession(repo=_Repo(), rows=[row])
await fp.reset_repository_projection(session, "demo", derived=d) await fp.reset_repository_projection(session, "demo", derived=d)
assert row.title == "Real Title" 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"