feat(forge): report unreadable repositories as unreadable (STATE-WP-0084-T01)
A private repository failed derivation the same way a broken one did, so "cannot read" and "does not exist" were indistinguishable from outside. They authorise opposite things: only the second can justify retiring a record. - ForgeUnreadableError (a ForgeDeriveError, so old callers still catch it) for permission-shaped clone failures, including Forgejo's 404 for an unauthenticated private repo — indistinguishable here, and the safe reading of an ambiguous answer cannot destroy a record. - GIT_TERMINAL_PROMPT=0: an unattended pass must fail, not block on a username prompt. Failing is what makes the case observable. - DerivedProjection.retirement_eligible separates "no records found" from "no records exist". A checkout with no workplans/ directory cannot evidence an absence — the empty-clone path that would have proposed every record in a repository for retirement. - Retirement from an ineligible source is refused even when acknowledged. - Fleet keeps unreadable out of the error bucket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 3377672@bnt-lap001 Assistant-Session: 15463ccf-238f-4e13-b163-93aa25c6d166
This commit is contained in:
parent
8b38f815e5
commit
85181cd3e4
3 changed files with 294 additions and 6 deletions
|
|
@ -12,6 +12,7 @@ what makes the reset in `T03` verifiable: you can always ask what the projection
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
|
@ -38,6 +39,50 @@ class ForgeDeriveError(RuntimeError):
|
|||
"""The repository could not be read from the forge."""
|
||||
|
||||
|
||||
class ForgeUnreadableError(ForgeDeriveError):
|
||||
"""Central is not permitted to read this repository — a policy, not a fault.
|
||||
|
||||
`ADR-012`'s premise (the forge is the projection source) holds only for
|
||||
repositories central can read, and until `STATE-WP-0084` nothing said so:
|
||||
a private repository failed the same way a broken one did, so "cannot read"
|
||||
and "does not exist" were indistinguishable from the outside.
|
||||
|
||||
They must never be confused, because they authorise opposite things. A
|
||||
repository that does not derive may have had its files removed deliberately;
|
||||
a repository we cannot read tells us nothing at all about its files. Only
|
||||
the first can justify retiring a record.
|
||||
|
||||
Forgejo answers an unauthenticated request for a private repository with a
|
||||
404, so "not found" is classified as unreadable too. That is deliberate: the
|
||||
two cases are genuinely indistinguishable at this layer, and the safe
|
||||
reading of an ambiguous answer is the one that cannot destroy a record.
|
||||
"""
|
||||
|
||||
|
||||
# git says this in several ways depending on version, transport, and whether a
|
||||
# credential helper is installed; all of them mean the same thing here.
|
||||
_UNREADABLE_MARKERS = (
|
||||
"could not read username",
|
||||
"could not read password",
|
||||
"authentication failed",
|
||||
"terminal prompts disabled",
|
||||
"invalid username or password",
|
||||
"403 forbidden",
|
||||
"the requested url returned error: 403",
|
||||
"the requested url returned error: 401",
|
||||
"repository not found",
|
||||
"remote: not found",
|
||||
"does not appear to be a git repository",
|
||||
)
|
||||
|
||||
|
||||
def _is_unreadable(message: str) -> bool:
|
||||
low = message.lower()
|
||||
if "not found" in low and "fatal: repository" in low:
|
||||
return True
|
||||
return any(marker in low for marker in _UNREADABLE_MARKERS)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DerivedTask:
|
||||
record_id: str
|
||||
|
|
@ -63,11 +108,28 @@ class DerivedProjection:
|
|||
repo_slug: str
|
||||
commit: str
|
||||
workplans: list[DerivedWorkplan] = field(default_factory=list)
|
||||
# False when the checkout has no `workplans/` directory at all. An empty
|
||||
# projection then means "we did not find the records", which is not the same
|
||||
# claim as "this repository has no records" — and only the second one could
|
||||
# ever justify retiring anything.
|
||||
records_source_present: bool = True
|
||||
|
||||
@property
|
||||
def task_count(self) -> int:
|
||||
return sum(len(w.tasks) for w in self.workplans)
|
||||
|
||||
@property
|
||||
def retirement_eligible(self) -> bool:
|
||||
"""Whether an absence in this projection is evidence of an absence.
|
||||
|
||||
A projection that could not be read never reaches this: it raises. What
|
||||
this rules out is the quieter case — a clone that succeeded and returned
|
||||
nothing, which is what would have retired every record in a repository
|
||||
had a clone ever come back empty instead of failing (`STATE-WP-0084-T01`;
|
||||
the near-miss was `vergabe-teilnahme`).
|
||||
"""
|
||||
return self.records_source_present
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": "state-hub.forge-projection.v1",
|
||||
|
|
@ -75,6 +137,8 @@ class DerivedProjection:
|
|||
# Provenance is not optional: a projection that cannot name the
|
||||
# commit it came from cannot be audited (ADR-012 decision 2).
|
||||
"commit": self.commit,
|
||||
"records_source_present": self.records_source_present,
|
||||
"retirement_eligible": self.retirement_eligible,
|
||||
"workplans": [
|
||||
{
|
||||
"record_id": w.record_id,
|
||||
|
|
@ -100,8 +164,12 @@ class DerivedProjection:
|
|||
|
||||
|
||||
def _run_git(*args: str, cwd: str | None = None, timeout: float = 120.0) -> str:
|
||||
# Without this a clone of a private repository blocks on a username prompt
|
||||
# instead of failing, and an unattended derivation pass hangs rather than
|
||||
# reporting. Failing fast is what makes the unreadable case observable.
|
||||
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": "", "GCM_INTERACTIVE": "never"}
|
||||
proc = subprocess.run(
|
||||
["git", *args], cwd=cwd, capture_output=True, text=True, timeout=timeout
|
||||
["git", *args], cwd=cwd, capture_output=True, text=True, timeout=timeout, env=env
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise ForgeDeriveError((proc.stderr or proc.stdout).strip()[:400])
|
||||
|
|
@ -161,6 +229,7 @@ def derive_from_checkout(repo_root: Path, repo_slug: str, commit: str) -> Derive
|
|||
proj = DerivedProjection(repo_slug=repo_slug, commit=commit)
|
||||
wp_dir = repo_root / "workplans"
|
||||
if not wp_dir.is_dir():
|
||||
proj.records_source_present = False
|
||||
return proj
|
||||
for path in sorted(wp_dir.rglob("*.md")):
|
||||
if path.name.startswith("."):
|
||||
|
|
@ -208,6 +277,14 @@ def derive_from_forge(
|
|||
_run_git(*args, url, tmp)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise ForgeDeriveError(f"clone timed out for {repo_slug}") from exc
|
||||
except ForgeDeriveError as exc:
|
||||
# Classify before propagating. A caller that cannot tell "not
|
||||
# permitted" from "broken" will eventually treat one as the other.
|
||||
if _is_unreadable(str(exc)):
|
||||
raise ForgeUnreadableError(
|
||||
f"{repo_slug} could not be read from the forge: {exc}"
|
||||
) from exc
|
||||
raise
|
||||
commit = _run_git("rev-parse", "HEAD", cwd=tmp)
|
||||
return derive_from_checkout(Path(tmp), repo_slug, commit)
|
||||
|
||||
|
|
@ -241,6 +318,9 @@ class ProjectionDiff:
|
|||
missing: list[dict[str, Any]] = field(default_factory=list) # forge has, hub lacks
|
||||
stale: list[dict[str, Any]] = field(default_factory=list) # hub has, forge lacks
|
||||
differing: list[dict[str, Any]] = field(default_factory=list) # both, fields differ
|
||||
# Set when the source could not support a claim of absence, so `stale` was
|
||||
# deliberately left empty rather than computed (`STATE-WP-0084-T01`).
|
||||
stale_withheld: str | None = None
|
||||
|
||||
@property
|
||||
def clean(self) -> bool:
|
||||
|
|
@ -261,6 +341,7 @@ class ProjectionDiff:
|
|||
"stale": len(self.stale),
|
||||
"differing": len(self.differing),
|
||||
},
|
||||
"stale_withheld": self.stale_withheld,
|
||||
"missing": self.missing,
|
||||
"stale": self.stale,
|
||||
"differing": self.differing,
|
||||
|
|
@ -278,6 +359,14 @@ def diff_against_hub(
|
|||
is testable without a database and cannot accidentally mutate anything.
|
||||
"""
|
||||
d = ProjectionDiff(repo_slug=derived.repo_slug, commit=derived.commit)
|
||||
if not derived.retirement_eligible:
|
||||
# Compute what is missing and what differs as usual — those only ever
|
||||
# add or correct. Absence is the one conclusion this source cannot
|
||||
# support, so it is not drawn at all rather than drawn and then filtered.
|
||||
d.stale_withheld = (
|
||||
"the checkout has no workplans/ directory, so an absent record is "
|
||||
"unexplained rather than evidence of removal"
|
||||
)
|
||||
|
||||
# Match on canonical identity, never on UUID. Most hub records still carry
|
||||
# pre-ADR-007 random identifiers, so a UUID-keyed comparison reports every
|
||||
|
|
@ -314,6 +403,8 @@ def diff_against_hub(
|
|||
d.missing.append({"kind": "workplan", "record_id": w.record_id, "uuid": w.uuid})
|
||||
for key, w in have_wp.items():
|
||||
if key not in want_wp:
|
||||
if d.stale_withheld:
|
||||
continue
|
||||
uid = str(w["id"])
|
||||
d.stale.append(
|
||||
{
|
||||
|
|
@ -371,6 +462,8 @@ def diff_against_hub(
|
|||
)
|
||||
for key, t in have.items():
|
||||
if key not in want:
|
||||
if d.stale_withheld:
|
||||
continue
|
||||
uid = str(t["id"])
|
||||
d.stale.append(
|
||||
{"kind": "task", "uuid": uid, "title": t.get("title"),
|
||||
|
|
@ -397,7 +490,7 @@ def diff_against_hub(
|
|||
class ResetOutcome:
|
||||
repo_slug: str
|
||||
commit: str
|
||||
status: str # applied | refused | noop
|
||||
status: str # applied | refused | noop | unreadable
|
||||
created: list[str] = field(default_factory=list)
|
||||
updated: list[str] = field(default_factory=list)
|
||||
retired: list[str] = field(default_factory=list)
|
||||
|
|
@ -460,7 +553,23 @@ async def reset_repository_projection(
|
|||
from api.models.task import Task
|
||||
from api.models.workplan import Workplan
|
||||
|
||||
derived = derived or derive_from_forge(repo_slug, forge_base=forge_base)
|
||||
if derived is None:
|
||||
try:
|
||||
derived = derive_from_forge(repo_slug, forge_base=forge_base)
|
||||
except ForgeUnreadableError as exc:
|
||||
# Not an error: a statement about what central is permitted to see.
|
||||
# Reported as its own status so a caller cannot mistake it for a
|
||||
# repository whose records stopped deriving (`STATE-WP-0084-T01`).
|
||||
out = ResetOutcome(repo_slug=repo_slug, commit="", status="unreadable")
|
||||
out.refused.append(
|
||||
{"reason": "repository could not be read from the forge", "slug": repo_slug,
|
||||
"detail": str(exc)[:300]}
|
||||
)
|
||||
out.notes.append(
|
||||
"Nothing was changed and nothing was retired. This says nothing "
|
||||
"about whether the repository's records still exist."
|
||||
)
|
||||
return out
|
||||
outcome = ResetOutcome(repo_slug=repo_slug, commit=derived.commit, status="noop")
|
||||
|
||||
repo = (
|
||||
|
|
@ -571,6 +680,27 @@ async def reset_repository_projection(
|
|||
r for k, r in matched.items()
|
||||
if k not in want and r.projection_retired_at is None
|
||||
]
|
||||
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
|
||||
# been shown to have stopped deriving, because the source produced no
|
||||
# records to compare against. Consenting to a conclusion is not the
|
||||
# same as the evidence for it existing.
|
||||
outcome.status = "refused"
|
||||
for r in stale:
|
||||
outcome.refused.append(
|
||||
{
|
||||
"reason": "source produced no records; absence is unexplained",
|
||||
"slug": r.slug,
|
||||
"status": r.status,
|
||||
"backing_relative_path": r.backing_relative_path,
|
||||
}
|
||||
)
|
||||
outcome.notes.append(
|
||||
"The checkout has no workplans/ directory. Retirement withheld "
|
||||
"regardless of acknowledgement (STATE-WP-0084-T01)."
|
||||
)
|
||||
return outcome
|
||||
if stale and not acknowledge_retirements:
|
||||
outcome.status = "refused"
|
||||
for r in stale:
|
||||
|
|
@ -665,6 +795,11 @@ async def reset_repository_projection(
|
|||
class FleetResetOutcome:
|
||||
results: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
errors: dict[str, str] = field(default_factory=dict)
|
||||
# Kept apart from `errors` on purpose. Nine repositories sitting in an error
|
||||
# bucket read as nine broken repositories; they were nine we were not
|
||||
# allowed to read, which is a different thing to go and fix
|
||||
# (`STATE-WP-0083-T04`, 2026-08-26).
|
||||
unreadable: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
by_status: dict[str, int] = {}
|
||||
|
|
@ -672,9 +807,11 @@ class FleetResetOutcome:
|
|||
by_status[r["status"]] = by_status.get(r["status"], 0) + 1
|
||||
return {
|
||||
"schema": "state-hub.fleet-projection-reset.v1",
|
||||
"repositories": len(self.results) + len(self.errors),
|
||||
"repositories": len(self.results) + len(self.errors) + len(self.unreadable),
|
||||
"by_status": by_status,
|
||||
"errored": len(self.errors),
|
||||
"unreadable_count": len(self.unreadable),
|
||||
"unreadable": self.unreadable,
|
||||
"totals": {
|
||||
k: sum(r["counts"][k] for r in self.results.values())
|
||||
for k in ("created", "updated", "retired", "refused")
|
||||
|
|
@ -719,7 +856,16 @@ async def reset_fleet_projection(
|
|||
await session.commit()
|
||||
else:
|
||||
await session.rollback()
|
||||
outcome.results[slug] = result.to_dict()
|
||||
if result.status == "unreadable":
|
||||
detail = next(
|
||||
(r.get("detail", "") for r in result.refused), ""
|
||||
)
|
||||
outcome.unreadable[slug] = detail or "could not be read from the forge"
|
||||
else:
|
||||
outcome.results[slug] = result.to_dict()
|
||||
except ForgeUnreadableError as exc:
|
||||
# Reachable when the caller supplied its own derivation path.
|
||||
outcome.unreadable[slug] = str(exc)[:300]
|
||||
except Exception as exc: # noqa: BLE001 - one repo must not end the pass
|
||||
outcome.errors[slug] = f"{type(exc).__name__}: {exc}"[:300]
|
||||
return outcome
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue