sync identified tasks of existing workplans on projection reset
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:
parent
bb1b8f4c95
commit
3204baf8fd
2 changed files with 210 additions and 13 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue