fix(backfill): source task identities from the forge, not a workstation
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 26s

The first implementation took local filesystem paths. Central has no workstation
checkout and must not depend on one: ADR-012 decision 1 makes the forge the
projection source, and a backfill reading someone's laptop would reintroduce the
exact coupling that ADR removes.

Surfaced concretely — central's postgres is not reachable from the workstation
(only the API tunnel, which is HTTP), so the local-path variant cannot reach the
database it needs to update, while the pod can clone the forge and already holds
the connection.

A repository that cannot be cloned contributes nothing rather than reducing what
the rest can identify.

Refs STATE-WP-0083-T06

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
This commit is contained in:
tegwick 2026-08-26 02:11:23 +02:00
parent de853fc9e6
commit cdd5cef373

View file

@ -114,3 +114,78 @@ async def backfill_task_record_ids(
if not dry_run:
row.record_id = record_id
return report
async def backfill_from_forge(
session: Any,
repo_slugs: list[str],
*,
forge_base: str | None = None,
dry_run: bool = True,
) -> BackfillReport:
"""Backfill from repositories cloned out of the forge.
The local-path variant above needs a workstation checkout, which central
does not have and should not depend on: `ADR-012` decision 1 makes the forge
the projection source, and a backfill sourced from someone's laptop would
reintroduce exactly the coupling that ADR removes.
Central can clone the forge directly, so it reads the pairing from the same
place it derives everything else.
"""
import tempfile
from api.services.forge_projection import (
DEFAULT_FORGE_BASE,
ForgeDeriveError,
_run_git,
)
base = forge_base or DEFAULT_FORGE_BASE
report = BackfillReport()
pairs: dict[str, str] = {}
for slug in repo_slugs:
url = f"{base.rstrip('/')}/{slug}.git"
with tempfile.TemporaryDirectory(prefix=f"backfill-{slug}-") as tmp:
try:
_run_git("clone", "--depth", "1", "--quiet", url, tmp)
except (ForgeDeriveError, Exception):
# A repository that cannot be read contributes nothing. It must
# not silently reduce what the rest can identify.
continue
repo_pairs, repo_report = collect_pairs([Path(tmp)])
report.scanned_files += repo_report.scanned_files
report.conflicts.extend(repo_report.conflicts)
for uid, rid in repo_pairs.items():
prior = pairs.get(uid)
if prior and prior != rid:
report.conflicts.append({"uuid": uid, "first": prior, "second": rid})
continue
pairs[uid] = rid
report.pairs_found = len(pairs)
if not pairs:
return report
from sqlalchemy import select
from api.models.task import Task
rows = list((await session.execute(select(Task))).scalars())
by_id = {str(r.id): r for r in rows}
for uid, rid in pairs.items():
row = by_id.get(uid)
if row is None:
report.unmatched_uuids += 1
continue
if row.record_id == rid:
report.already_set += 1
continue
if row.record_id and row.record_id != rid:
report.conflicts.append({"uuid": uid, "first": row.record_id, "second": rid})
continue
report.updated += 1
if not dry_run:
row.record_id = rid
return report