Guard Forge retirement with an exact expected UUID set
Blanket acknowledgement is unchanged when the UUID list is omitted. A present expected_retirement_uuids set must equal the stale projections, and the call retires nothing when that set mismatches, a stale row still has tasks or dependency edges, or both authorizations are set together. Assistant: grok Assistant-Session: 01a10946-16b1-7973-b1dd-71fc58b8fcf0
This commit is contained in:
parent
3fc5f72076
commit
968a8117f4
5 changed files with 448 additions and 5 deletions
|
|
@ -131,6 +131,7 @@ async def reconcile_repository_work_records(
|
|||
session,
|
||||
slug,
|
||||
acknowledge_retirements=body.acknowledge_retirements,
|
||||
expected_retirement_uuids=body.expected_retirement_uuids,
|
||||
derived=derived,
|
||||
)
|
||||
if outcome.status in {"applied", "noop"} or outcome.released:
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ from api.schemas.workplan_dependency import WorkplanDependencyRead
|
|||
class RepositoryProjectionReconcile(BaseModel):
|
||||
expected_commit: str
|
||||
acknowledge_retirements: bool = False
|
||||
# None keeps today's blanket acknowledgement. An empty list is present and
|
||||
# means the caller expects zero retirements. Combined with
|
||||
# acknowledge_retirements, the call is ambiguous and retires nothing.
|
||||
expected_retirement_uuids: list[uuid.UUID] | None = None
|
||||
|
||||
@field_validator("expected_commit")
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -1071,6 +1071,81 @@ def _tombstone_slug(slug: str, when: datetime) -> str:
|
|||
RETIRE_REASON = "no longer derived from the forge"
|
||||
|
||||
|
||||
def _uuid_key(value: Any) -> str:
|
||||
return str(uuid.UUID(str(value)))
|
||||
|
||||
|
||||
async def _exact_retirement_blockers(session: Any, stale: list[Any]) -> list[dict[str, Any]]:
|
||||
"""Block an exact retirement when any stale row still has tasks or edges.
|
||||
|
||||
Zero means zero. Terminal tasks count, and so does a dependency edge in
|
||||
either direction. The caller retires nothing unless this returns empty.
|
||||
"""
|
||||
if not stale:
|
||||
return []
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from api.models.task import Task
|
||||
from api.models.workplan_dependency import WorkplanDependency
|
||||
|
||||
stale_ids = [row.id for row in stale]
|
||||
tasks = list(
|
||||
(
|
||||
await session.execute(select(Task).where(Task.workplan_id.in_(stale_ids)))
|
||||
).scalars()
|
||||
)
|
||||
edges = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(WorkplanDependency).where(
|
||||
or_(
|
||||
WorkplanDependency.from_workplan_id.in_(stale_ids),
|
||||
WorkplanDependency.to_workplan_id.in_(stale_ids),
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
refused: list[dict[str, Any]] = []
|
||||
for row in stale:
|
||||
key = str(row.id)
|
||||
row_tasks = [task for task in tasks if str(task.workplan_id) == key]
|
||||
row_edges = [
|
||||
edge
|
||||
for edge in edges
|
||||
if str(edge.from_workplan_id) == key
|
||||
or (edge.to_workplan_id is not None and str(edge.to_workplan_id) == key)
|
||||
]
|
||||
if row_tasks:
|
||||
refused.append(
|
||||
{
|
||||
"reason": "stale projection has tasks",
|
||||
"uuid": key,
|
||||
"slug": row.slug,
|
||||
"task_ids": [str(task.id) for task in row_tasks],
|
||||
}
|
||||
)
|
||||
if row_edges:
|
||||
refused.append(
|
||||
{
|
||||
"reason": "stale projection has dependency edges",
|
||||
"uuid": key,
|
||||
"slug": row.slug,
|
||||
"dependency_ids": [str(edge.id) for edge in row_edges],
|
||||
}
|
||||
)
|
||||
if (tasks or edges) and not refused:
|
||||
refused.append(
|
||||
{
|
||||
"reason": "stale projection has tasks or dependency edges",
|
||||
"task_ids": [str(task.id) for task in tasks],
|
||||
"dependency_ids": [str(edge.id) for edge in edges],
|
||||
}
|
||||
)
|
||||
return refused
|
||||
|
||||
|
||||
def _coerce_task_status(raw: str | None) -> Any:
|
||||
from api.models.task import TaskStatus
|
||||
|
||||
|
|
@ -1202,6 +1277,7 @@ async def reset_repository_projection(
|
|||
repo_slug: str,
|
||||
*,
|
||||
acknowledge_retirements: bool = False,
|
||||
expected_retirement_uuids: list[uuid.UUID] | None = None,
|
||||
forge_base: str = DEFAULT_FORGE_BASE,
|
||||
derived: DerivedProjection | None = None,
|
||||
) -> ResetOutcome:
|
||||
|
|
@ -1215,7 +1291,10 @@ async def reset_repository_projection(
|
|||
|
||||
Retirement is refused by default. A record that stops deriving may mean the
|
||||
file was removed deliberately — or that someone pointed this at the wrong
|
||||
branch. The caller must say which.
|
||||
branch. The caller must say which, either by blanket acknowledgement or by
|
||||
the exact hub UUIDs expected to retire. The two authorizations cannot be
|
||||
combined. Omitting the UUID list keeps blanket acknowledgement unchanged;
|
||||
an empty list expects zero retirements.
|
||||
|
||||
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
|
||||
|
|
@ -1480,6 +1559,24 @@ async def reset_repository_projection(
|
|||
r for k, r in matched.items()
|
||||
if k not in want and r.projection_retired_at is None
|
||||
] + [r for r in displaced if r.projection_retired_at is None]
|
||||
# Exact UUIDs and blanket acknowledgement are different authorizations.
|
||||
# A call that sets both retires nothing, before any retirement mutation.
|
||||
if expected_retirement_uuids is not None and acknowledge_retirements:
|
||||
outcome.status = "refused"
|
||||
outcome.refused.append(
|
||||
{
|
||||
"reason": "ambiguous authorization",
|
||||
"detail": (
|
||||
"expected_retirement_uuids and acknowledge_retirements "
|
||||
"cannot both be set"
|
||||
),
|
||||
}
|
||||
)
|
||||
outcome.notes.append(
|
||||
"Nothing was retired. Pass either an exact retirement set or "
|
||||
"blanket acknowledgement, not both."
|
||||
)
|
||||
return outcome
|
||||
if stale and not derived.retirement_eligible:
|
||||
# Acknowledgement cannot authorise this. The caller is confirming that
|
||||
# records which stopped deriving should be retired; here nothing has
|
||||
|
|
@ -1501,7 +1598,37 @@ async def reset_repository_projection(
|
|||
"regardless of acknowledgement (STATE-WP-0084-T01)."
|
||||
)
|
||||
return outcome
|
||||
if stale and not acknowledge_retirements:
|
||||
if expected_retirement_uuids is not None:
|
||||
# `stale` is the unmatched live rows plus the displaced live rows.
|
||||
# A row that still matches, including a historical parent, is not in it.
|
||||
actual = {_uuid_key(row.id) for row in stale}
|
||||
expected = {_uuid_key(value) for value in expected_retirement_uuids}
|
||||
missing = sorted(actual - expected)
|
||||
extra = sorted(expected - actual)
|
||||
if missing or extra:
|
||||
outcome.status = "refused"
|
||||
outcome.refused.append(
|
||||
{
|
||||
"reason": "expected retirement set does not match stale projections",
|
||||
"missing": missing,
|
||||
"extra": extra,
|
||||
}
|
||||
)
|
||||
outcome.notes.append(
|
||||
"Nothing was retired. The expected set must equal the stale "
|
||||
"set; partial retirement is not applied."
|
||||
)
|
||||
return outcome
|
||||
blockers = await _exact_retirement_blockers(session, stale)
|
||||
if blockers:
|
||||
outcome.status = "refused"
|
||||
outcome.refused.extend(blockers)
|
||||
outcome.notes.append(
|
||||
"Nothing was retired. A stale projection with tasks or "
|
||||
"dependency edges cannot be retired."
|
||||
)
|
||||
return outcome
|
||||
elif stale and not acknowledge_retirements:
|
||||
outcome.status = "refused"
|
||||
for r in stale:
|
||||
outcome.refused.append(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue