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)
# 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

View file

@ -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"