state-hub/api/services/forge_projection.py
tegwick 34f5cb3fc3
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 24s
feat: add fast forge work-record reconciliation
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
2026-08-30 22:38:54 +02:00

1304 lines
53 KiB
Python

"""Derive a repository's work-record projection from the forge (STATE-WP-0083-T01).
`ADR-012` decision 1 makes the forge the projection source. Central does its own
reading: it clones the repository's default branch and derives from that, rather
than accepting a projection computed elsewhere — which `ADR-010` decision 5
forbids.
This module is read-only. Deriving must be safe to run at any time, because it is
what makes the reset in `T03` verifiable: you can always ask what the projection
*should* be without changing anything.
"""
from __future__ import annotations
import os
import base64
import re
import subprocess
import tempfile
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
# Same derivation as ADR-007 / repo-manager, so a forge-derived projection and a
# preliminary overlay compute identical identities for the same record.
from api.services.task_record_id_backfill import qualify_task_id
_WORK_RECORD_NAMESPACE = uuid.UUID("a4058507-5c4a-5a00-ab06-fffa4fb46009")
_TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL)
_HEADING_RE = re.compile(r"^(#{1,4})\s+(.+?)$", re.MULTILINE)
DEFAULT_FORGE_BASE = "https://forgejo.coulomb.social/coulomb"
_IDENTIFIER_SLUG = re.compile(
r"^[a-z0-9-]*-wp-\d{3,}(?:-t\d{2})?$" # PREFIX-WP-0001, -T01
r"|^[a-z0-9-]*adhoc[a-z0-9-]*?-\d{4}-\d{2}-\d{2}(?:-t\d{2})?$" # daily ad-hoc, qualified either side
)
def _slug_is_identifier(slug: str) -> bool:
"""Whether a row's slug is a work-record identifier or a title slug.
This is what separates a re-key from a rename when the path cannot. A row
whose slug is an identifier is *claiming* to be that record, so if the forge
derives a different identifier for the same file, they are two records and
the old one retires. A row whose slug is a title (`three-phoenix-ha-cluster`
for `RCLUSTER-WP-0007`) never claimed one: those are hub-first rows from
before ADR-001, where the backing path is the only link there has ever been,
and path matching is the only thing that can hold them together.
"""
return bool(_IDENTIFIER_SLUG.match((slug or "").strip().lower()))
def _identity_is_derived(row: Any) -> bool:
"""Whether this row's UUID was derived from its identifier (ADR-007).
Derived rows are UUIDv5 over the work-record namespace. Rows predating
derived identity carry a random v4 UUID, and for those the identifier is a
label rather than the identity — so the path fallback still applies, which
is what keeps rename detection working for legacy records.
"""
rid = getattr(row, "id", None)
return getattr(rid, "version", None) == 5
def derived_record_uuid(record_id: str) -> str:
return str(uuid.uuid5(_WORK_RECORD_NAMESPACE, f"helixforge\n{record_id}"))
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
uuid: str
title: str | None
status: str | None
priority: str | None
description: str | None = None
needs_human: bool = False
intervention_note: str | None = None
blocking_reason: str | None = None
@dataclass
class DerivedWorkplan:
record_id: str
uuid: str
title: str | None
status: str | None
relative_path: str
archived: bool
owner: str | None = None
description: str | None = None
tasks: list[DerivedTask] = field(default_factory=list)
@dataclass
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",
"repo_slug": self.repo_slug,
# 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,
"uuid": w.uuid,
"title": w.title,
"status": w.status,
"owner": w.owner,
"description": w.description,
"relative_path": w.relative_path,
"archived": w.archived,
"tasks": [
{
"record_id": t.record_id,
"uuid": t.uuid,
"title": t.title,
"status": t.status,
"priority": t.priority,
"description": t.description,
"needs_human": t.needs_human,
"intervention_note": t.intervention_note,
"blocking_reason": t.blocking_reason,
}
for t in w.tasks
],
}
for w in self.workplans
],
}
from api.services.forge_credential import forge_read_token # noqa: F401
# Kept as module attributes so callers and tests that reached for them here
# still resolve after the sources moved to `forge_credential`.
FORGE_TOKEN_ENV = "FORGE_READ_TOKEN"
FORGE_TOKEN_FILE_ENV = "FORGE_READ_TOKEN_FILE"
def _credential_env(token: str | None) -> dict[str, str]:
"""Git config carrying the credential, passed by environment not argv.
`-c http.extraHeader=...` would place the token in the process command line,
where it is readable by anything that can run `ps` and lands in any log that
records invocations. GIT_CONFIG_* achieves the same configuration without
that exposure.
"""
if not token:
return {}
header = base64.b64encode(f"x-access-token:{token}".encode()).decode()
return {
"GIT_CONFIG_COUNT": "1",
"GIT_CONFIG_KEY_0": "http.extraHeader",
"GIT_CONFIG_VALUE_0": f"Authorization: Basic {header}",
}
def _run_git(
*args: str, cwd: str | None = None, timeout: float = 120.0, token: str | None = None
) -> 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"}
env.update(_credential_env(token))
proc = subprocess.run(
["git", *args], cwd=cwd, capture_output=True, text=True, timeout=timeout, env=env
)
if proc.returncode != 0:
detail = (proc.stderr or proc.stdout).strip()[:400]
if token:
# Never let a credential reach an exception that is logged, stored
# in a reset outcome, or returned over the API.
detail = detail.replace(token, "***")
raise ForgeDeriveError(detail)
return proc.stdout.strip()
def _split_frontmatter(text: str) -> tuple[dict, str]:
if not text.startswith("---"):
return {}, text
end = text.find("\n---", 3)
if end == -1:
return {}, text
raw = text[3:end]
body = text[end + 4 :]
try:
meta = yaml.safe_load(raw) or {}
except yaml.YAMLError:
return {}, text
return (meta if isinstance(meta, dict) else {}), body
def _parse_tasks(body: str, workplan_id: str) -> list[DerivedTask]:
headings = [
(m.start(), m.group(2).strip()) for m in _HEADING_RE.finditer(body)
]
out: list[DerivedTask] = []
for m in _TASK_BLOCK_RE.finditer(body):
try:
block = yaml.safe_load(m.group(1).strip()) or {}
except yaml.YAMLError:
continue
if not isinstance(block, dict):
continue
rid = str(block.get("id") or "").strip()
if not rid:
continue
title = block.get("title")
if not title:
prev = [t for pos, t in headings if pos < m.start()]
title = prev[-1] if prev else None
following_headings = [pos for pos, _title in headings if pos > m.end()]
description_end = min(following_headings) if following_headings else len(body)
description = str(block.get("description") or "").strip()
if not description:
description = body[m.end() : description_end].strip()
out.append(
DerivedTask(
# A bare `T01` is not an identifier: it is unique only within
# its workplan, so `uuid5("T01")` is the same UUID for every
# workplan in the fleet. llm-connect's 91 task blocks derive
# just 49 distinct UUIDs unqualified, and creating its
# workplans fails on a duplicate task primary key.
#
# Qualifying with the owning workplan is the same rule
# `task_record_id_backfill.qualify_task_id` applies to stored
# ids; both must agree or the backfill and the projection
# disagree about what a task is called.
record_id=qualify_task_id(rid, workplan_id) or rid,
# Derived, not read from the file: the forge projection must not
# inherit an identifier the file happens to carry.
uuid=derived_record_uuid(qualify_task_id(rid, workplan_id) or rid),
title=title,
status=(str(block["status"]).strip() if block.get("status") else None),
priority=(str(block["priority"]).strip() if block.get("priority") else None),
description=description or None,
needs_human=bool(block.get("needs_human", False)),
intervention_note=(
str(block["intervention_note"]).strip()
if block.get("intervention_note")
else None
),
blocking_reason=(
str(block["blocking_reason"]).strip()
if block.get("blocking_reason")
else None
),
)
)
return out
def _workplan_description(body: str) -> str | None:
"""Return bounded prose under ``## Goal`` when one is present."""
match = re.search(r"^##\s+Goal\s*$", body, re.MULTILINE | re.IGNORECASE)
if match is None:
return None
remainder = body[match.end() :]
next_heading = re.search(r"^##\s+", remainder, re.MULTILINE)
if next_heading is not None:
remainder = remainder[: next_heading.start()]
value = remainder.strip()
return value[:4000] or None
def derive_from_checkout(repo_root: Path, repo_slug: str, commit: str) -> DerivedProjection:
"""Derive a projection from an already-materialised checkout."""
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("."):
continue
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
meta, body = _split_frontmatter(text)
if str(meta.get("type") or "").strip() != "workplan":
continue
rid = str(meta.get("id") or "").strip()
if not rid:
continue
proj.workplans.append(
DerivedWorkplan(
record_id=rid,
uuid=derived_record_uuid(rid),
title=(str(meta["title"]).strip() if meta.get("title") else None),
status=(str(meta["status"]).strip() if meta.get("status") else None),
relative_path=str(path.relative_to(repo_root).as_posix()),
archived=path.parent.name == "archived",
owner=(str(meta["owner"]).strip() if meta.get("owner") else None),
description=(
str(meta["description"]).strip()
if meta.get("description")
else _workplan_description(body)
),
tasks=_parse_tasks(body, rid),
)
)
proj.workplans.sort(key=lambda w: w.record_id)
return proj
def derive_from_forge(
repo_slug: str, *, forge_base: str = DEFAULT_FORGE_BASE, ref: str | None = None
) -> DerivedProjection:
"""Clone the repository's default branch from the forge and derive from it.
A fresh shallow clone every time, deliberately: the source is what the forge
holds now, and a reused working copy is how a projection ends up reflecting
someone's local state instead (ADR-012 context).
"""
url = f"{forge_base.rstrip('/')}/{repo_slug}.git"
token = forge_read_token()
with tempfile.TemporaryDirectory(prefix=f"forge-{repo_slug}-") as tmp:
args = ["clone", "--depth", "1", "--quiet"]
if ref:
args += ["--branch", ref]
try:
_run_git(*args, url, tmp, token=token)
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)
# ---------------------------------------------------------------------------
# Comparison against what the hub currently holds (STATE-WP-0083-T02)
# ---------------------------------------------------------------------------
_ARCHIVE_PREFIX_RE = re.compile(r"^\d{6}-")
def _path_key(path: str) -> str:
"""Normalise a workplan path so an archived copy matches its live one."""
name = path.rsplit("/", 1)[-1]
return _ARCHIVE_PREFIX_RE.sub("", name).strip().lower()
@dataclass
class ProjectionDiff:
"""What a reset would change, computed without changing anything.
This is what makes `ADR-012` decision 7's "verifiable" real: the reset can
always be inspected before it runs, and its result compared against the forge
afterwards.
"""
repo_slug: str
commit: str
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:
return not (self.missing or self.stale or self.differing)
@property
def would_remove(self) -> int:
return len(self.stale)
def to_dict(self) -> dict[str, Any]:
return {
"schema": "state-hub.projection-diff.v1",
"repo_slug": self.repo_slug,
"commit": self.commit,
"clean": self.clean,
"counts": {
"missing": len(self.missing),
"stale": len(self.stale),
"differing": len(self.differing),
},
"stale_withheld": self.stale_withheld,
"missing": self.missing,
"stale": self.stale,
"differing": self.differing,
}
def diff_against_hub(
derived: DerivedProjection,
hub_workplans: list[dict[str, Any]],
hub_tasks_by_workplan: dict[str, list[dict[str, Any]]],
) -> ProjectionDiff:
"""Compare a derived projection with the hub's current records.
Pure: takes the hub's state as data rather than reading it, so the comparison
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
# record as simultaneously missing and stale — and a reset built on that
# would destroy and recreate the entire projection. The canonical record id
# is what is stable across the identifier migration; the backing file is the
# fallback when a hub slug was derived from a filename rather than an id.
def _wp_key(record_id: str | None, slug: str | None, path: str | None) -> str:
if record_id:
return record_id.strip().lower()
if path:
return _path_key(path)
return (slug or "").strip().lower()
want_wp = {w.record_id.strip().lower(): w for w in derived.workplans}
have_wp: dict[str, dict[str, Any]] = {}
want_paths = {_path_key(w.relative_path): k for k, w in want_wp.items()}
for w in hub_workplans:
slug = str(w.get("slug") or "")
key = slug.strip().lower()
if key not in want_wp:
# Slugs were not always the canonical id; fall back to the file.
bp = w.get("backing_relative_path")
if bp and _path_key(bp) in want_paths:
key = want_paths[_path_key(bp)]
else:
cand = [k for k in want_wp if slug.lower().startswith(k + "-")]
if len(cand) == 1:
key = cand[0]
have_wp[key] = w
for key, w in want_wp.items():
if key not in have_wp:
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(
{
"kind": "workplan",
"uuid": uid,
"slug": w.get("slug"),
"status": w.get("status"),
# A stale record with no backing file is the case that must
# never be destroyed silently (ADR-012 decision 7).
"has_backing_file": bool(
w.get("backing_filename") or w.get("backing_relative_path")
),
}
)
for key, w in want_wp.items():
cur = have_wp.get(key)
uid = str(cur["id"]) if cur else w.uuid
if not cur:
continue
changed = {}
if (cur.get("status") or None) != (w.status or None):
changed["status"] = {"hub": cur.get("status"), "forge": w.status}
cur_path = cur.get("backing_relative_path") or None
if cur_path != w.relative_path:
changed["backing_relative_path"] = {"hub": cur_path, "forge": w.relative_path}
if changed:
d.differing.append(
{"kind": "workplan", "record_id": w.record_id, "uuid": uid, "changed": changed}
)
for w in derived.workplans:
hub_rows = hub_tasks_by_workplan.get(w.uuid, [])
if not hub_rows:
cur = have_wp.get(w.record_id.strip().lower())
if cur:
hub_rows = hub_tasks_by_workplan.get(str(cur["id"]), [])
# Match on the canonical record id where the hub has one. Rows created
# before STATE-WP-0083-T06 fall back to title, which is why those are
# reported rather than acted on: a renamed heading is indistinguishable
# from a replaced task under title matching.
def _task_key(record_id: str | None, title: str | None) -> str:
if record_id:
return record_id.strip().lower()
return "title:" + (title or "").strip().lower()
have = {
_task_key(t.get("record_id"), t.get("title")): t for t in hub_rows
}
want = {_task_key(t.record_id, t.title): t for t in w.tasks}
for key, t in want.items():
if key not in have:
d.missing.append(
{"kind": "task", "record_id": t.record_id, "uuid": t.uuid,
"workplan": w.record_id}
)
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"),
"status": t.get("status"), "workplan": w.record_id,
"has_backing_file": False}
)
for key, t in want.items():
cur = have.get(key)
if cur and (cur.get("status") or None) != (t.status or None):
d.differing.append(
{"kind": "task", "record_id": t.record_id, "uuid": t.uuid,
"workplan": w.record_id,
"changed": {"status": {"hub": cur.get("status"), "forge": t.status}}}
)
return d
# ---------------------------------------------------------------------------
# Applying the reset (STATE-WP-0083-T03)
# ---------------------------------------------------------------------------
@dataclass
class ResetOutcome:
repo_slug: str
commit: str
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)
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]:
return {
"schema": "state-hub.projection-reset.v1",
"repo_slug": self.repo_slug,
"commit": self.commit,
"status": self.status,
"counts": {
"created": len(self.created),
"updated": len(self.updated),
"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,
}
RETIRED_SLUG_MARK = "@retired-"
def _tombstone_slug(slug: str, when: datetime) -> str:
"""Release the identifier a retired row was holding.
`slug` is unique across the whole table, so retirement that only sets a
timestamp leaves the identifier locked to a record nothing derives any
more — and the repository that legitimately owns it can never claim it.
That is what kept repo-seed refused after core-hub's inherited REPO-WP rows
were retired.
The row, its UUID, and its progress events are untouched; only the
human-facing identifier is stamped, so history stays attached to the record
it happened under. Re-retiring an already-stamped row must not stack marks,
or the column overflows after a few passes.
"""
base = (slug or "").split(RETIRED_SLUG_MARK)[0]
stamped = f"{base}{RETIRED_SLUG_MARK}{when:%Y%m%d}"
return stamped[:100]
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] = {}
claimed: set[str] = set()
stale: list[Any] = []
by_uuid = {str(ht.id): ht for ht in hub_tasks}
for ht in hub_tasks:
rid = (ht.record_id or "").strip().lower()
if rid and rid in want:
matched[rid] = ht
claimed.add(str(ht.id))
elif rid:
stale.append(ht)
for key, dt in want.items():
ht = matched.get(key)
if ht is None and dt.uuid in by_uuid:
# Already on the hub under the derived UUID, but record_id was never
# written (the registrar-minted case). Overwriting would collide.
ht = by_uuid[dt.uuid]
matched[key] = ht
claimed.add(str(ht.id))
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),
"description": dt.description,
"needs_human": dt.needs_human,
"intervention_note": dt.intervention_note,
"blocking_reason": dt.blocking_reason,
}
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 not (ht.record_id or "").strip():
ht.record_id = dt.record_id
changed = True
if dt.title and dt.title.strip() and ht.title != dt.title.strip():
ht.title = dt.title.strip()
changed = True
if getattr(ht, "description", None) != dt.description:
ht.description = dt.description
changed = True
st = _coerce_task_status(dt.status)
if st is not None and ht.status != st:
ht.status = st
changed = True
if getattr(ht, "needs_human", False) != dt.needs_human:
ht.needs_human = dt.needs_human
changed = True
if getattr(ht, "intervention_note", None) != dt.intervention_note:
ht.intervention_note = dt.intervention_note
changed = True
if getattr(ht, "blocking_reason", None) != dt.blocking_reason:
ht.blocking_reason = dt.blocking_reason
changed = True
if dt.priority:
try:
task_priority = TaskPriority(dt.priority.strip().lower())
except ValueError:
task_priority = None
if task_priority is not None and getattr(ht, "priority", None) != task_priority:
ht.priority = task_priority
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,
*,
acknowledge_retirements: bool = False,
forge_base: str = DEFAULT_FORGE_BASE,
derived: DerivedProjection | None = None,
) -> ResetOutcome:
"""Reconcile one repository's workplan projection against the forge.
Creates what the forge has and the hub lacks, updates what differs, and
retires what no longer derives. It does not delete: hub-native records
reference workplans with `ON DELETE RESTRICT`, and destroying a progress
event to tidy a derived projection would lose hub-native truth to fix a
derived-state problem (`ADR-012` decision 7 as amended).
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.
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
from sqlalchemy import select
from api.models.managed_repo import ManagedRepo
from api.models.task import Task
from api.models.workplan import Workplan
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 = (
await session.execute(select(ManagedRepo).where(ManagedRepo.slug == repo_slug))
).scalar_one_or_none()
if repo is None:
outcome.status = "refused"
outcome.refused.append({"reason": "repository is not registered", "slug": repo_slug})
return outcome
rows = list(
(
await session.execute(select(Workplan).where(Workplan.repo_id == repo.id))
).scalars()
)
want = {w.record_id.strip().lower(): w for w in derived.workplans}
want_paths = {_path_key(w.relative_path): k for k, w in want.items()}
# The derived UUID is the strongest key there is: if a row's id equals
# uuid5 of a wanted record's identifier, that row *is* that record, whatever
# its slug says. Checking it first is what stops a correct record with a
# legacy title slug — `testdrive-jsui-publication` for MARKITECT-WP-0002 —
# from being read as a re-key and proposed for retirement.
want_by_uuid = {w.uuid: k for k, w in want.items()}
# Release identifiers held by rows retired before retirement freed them.
# This runs ahead of every refusal path, because it completes a decision
# already taken rather than making a new one — gating it behind an unrelated
# pending decision left four identifiers locked in repositories refusing for
# reasons having nothing to do with them.
#
# A row the forge still derives is exempt: stamping it would change the slug
# out from under the matching below, so the record would fail to match its
# own file and never be un-retired.
for r in rows:
if r.projection_retired_at is None or RETIRED_SLUG_MARK in (r.slug or ""):
continue
if (r.slug or "").strip().lower() in want or str(r.id) in want_by_uuid:
continue
before = r.slug
r.slug = _tombstone_slug(r.slug or str(r.id), r.projection_retired_at)
outcome.released.append(before)
# How a row came to claim a key, strongest first. Two rows can claim the
# same record — `cust-wp-0010` by its own identifier and
# `workstream-lifecycle-documentation` by path, both pointing at
# CUST-WP-0010's file. Assigning into `matched` unconditionally let the
# later one win and dropped the other silently: never matched, so never
# stale, so never reported by any pass.
UUID_MATCH, SLUG_MATCH, PATH_MATCH, PREFIX_MATCH = 0, 1, 2, 3
matched: dict[str, Any] = {}
claim: dict[str, int] = {}
displaced: list[Any] = []
def _claim(key: str, row: Any, strength: int) -> None:
held = claim.get(key)
if held is None:
matched[key], claim[key] = row, strength
return
if strength < held:
# The new claim is stronger; the incumbent loses the record and
# becomes a retirement candidate rather than disappearing.
displaced.append(matched[key])
matched[key], claim[key] = row, strength
else:
displaced.append(row)
for row in rows:
key = (row.slug or "").strip().lower()
by_uuid = want_by_uuid.get(str(row.id))
if by_uuid is not None:
_claim(by_uuid, row, UUID_MATCH)
continue
if key in want:
# The identifier itself derives again — including for a retired row,
# which is then deliberately un-retired below.
_claim(key, row, SLUG_MATCH)
continue
if row.projection_retired_at is None and RETIRED_SLUG_MARK in (row.slug or ""):
# A stamped slug with a cleared flag is a resurrected row: the mark
# is evidence a retirement happened and the flag says it did not.
# It must land in `matched` under its own key so it shows up as
# stale — skipping it outright, as a genuinely retired row is
# skipped, is what left these reporting `noop` forever.
matched[key] = row
continue
if row.projection_retired_at is not None:
# Already retired: nothing to do, and nothing to re-decide.
#
# Past this point only the heuristics remain, and a retired row must
# not be matched by those. Retirement is a
# decision, and re-deriving the same file must not silently undo it.
#
# Releasing the identifier makes this necessary: the tombstoned slug
# is no longer an identifier and a legacy row is not UUID-derived, so
# both re-key guards fall through to path matching — which matches
# the file the row was retired *for* and resurrects it, alongside the
# correct record already created from that same file.
#
# A record that genuinely returns matches by UUID or by its own
# identifier above, and is un-retired there.
continue
if key not in want:
if _identity_is_derived(row) or _slug_is_identifier(row.slug or ""):
# ADR-007: a record identified by an identifier *is* that
# identifier. Two cases reach here — a derived (v5) row, and a
# legacy row whose slug is still a work-record identifier. Both
# are re-keys, not renames.
#
# ADR-007: a derived record's identity *is* a function of its
# identifier, so a changed identifier means a different record —
# the old one retires and the new one is created. Matching it to
# the file by path instead would update the row in place while
# its UUID still encodes the old identifier, leaving the file
# and the hub disagreeing about what the record is called.
#
# This is not hypothetical: the ad-hoc requalification
# (CUST-WP-0066) deliberately keeps the filename, so for those
# records a re-key *never* changes the path. Path matching
# cannot tell a re-key from a rename there, and silently chose
# rename for all 30 of them.
#
# The row keeps its own slug as key, so it lands in `stale` and
# becomes a retirement candidate rather than vanishing.
matched[key] = row
continue
bp = row.backing_relative_path
if bp and _path_key(bp) in want_paths:
_claim(want_paths[_path_key(bp)], row, PATH_MATCH)
continue
cand = [k for k in want if key.startswith(k + "-")]
if len(cand) == 1:
_claim(cand[0], row, PREFIX_MATCH)
continue
matched[key] = row
# An identifier this repository would create may already belong to another
# repository. Two repositories creating the same daily identifier on the
# same day is a documented case (CUST-WP-0066), and the derivation is
# deliberately deterministic, so the collision is real rather than
# incidental. Refuse and say so: a constraint violation is a stack trace,
# a refusal is something the caller can rule on.
creating = [w for k, w in want.items() if k not in matched]
if creating:
foreign = list(
(
await session.execute(
select(Workplan).where(
Workplan.id.in_([uuid.UUID(w.uuid) for w in creating]),
Workplan.repo_id != repo.id,
)
)
).scalars()
)
# `slug` carries its own unique constraint across the whole table, so an
# identifier check alone is not enough: two repositories can derive
# different identifiers whose slugs still collide. Missing this is what
# left disaster-control raising IntegrityError after the identifier
# refusal was added.
slug_clash = list(
(
await session.execute(
select(Workplan).where(
Workplan.slug.in_([w.record_id.lower() for w in creating]),
Workplan.repo_id != repo.id,
)
)
).scalars()
)
if slug_clash:
held = {(r.slug or "").lower(): r for r in slug_clash}
outcome.status = "refused"
for w in creating:
row = held.get(w.record_id.lower())
if row is None:
continue
outcome.refused.append(
{
"reason": "slug already belongs to another repository",
"record_id": w.record_id,
"slug": w.record_id.lower(),
"held_by_id": str(row.id),
}
)
outcome.notes.append(
"Identifier collision is an identity decision, not a projection "
"one; acknowledging retirements does not authorise it."
)
return outcome
if foreign:
owned = {str(r.id): r for r in foreign}
outcome.status = "refused"
for w in creating:
held = owned.get(w.uuid)
if held is None:
continue
outcome.refused.append(
{
"reason": "derived identifier already belongs to another repository",
"record_id": w.record_id,
"uuid": w.uuid,
"held_by_slug": held.slug,
}
)
outcome.notes.append(
"Identifier collision is an identity decision, not a projection "
"one; acknowledging retirements does not authorise it."
)
return outcome
stale = [
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]
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:
outcome.refused.append(
{
"reason": "would be retired; the forge no longer derives it",
"slug": r.slug,
"status": r.status,
"backing_relative_path": r.backing_relative_path,
}
)
outcome.notes.append(
"Re-run with acknowledgement to retire these. Nothing was changed."
)
return outcome
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:
row = Workplan(
id=uuid.UUID(w.uuid),
repo_id=repo.id,
topic_id=repo.topic_id,
slug=w.record_id.lower(),
title=w.title or w.record_id,
description=w.description,
status=w.status or "proposed",
owner=w.owner,
backing_filename=w.relative_path.rsplit("/", 1)[-1],
backing_relative_path=w.relative_path,
backing_archived=w.archived,
derived_from_commit=derived.commit,
)
session.add(row)
await session.flush()
for t in w.tasks:
# Safe only because nothing exists to mis-match against: this
# workplan is new to the hub.
session.add(
Task(
id=uuid.UUID(t.uuid),
workplan_id=row.id,
record_id=t.record_id,
title=t.title or t.record_id,
description=t.description,
status=t.status or "todo",
priority=t.priority or "medium",
needs_human=t.needs_human,
intervention_note=t.intervention_note,
blocking_reason=t.blocking_reason,
)
)
outcome.created.append(w.record_id)
continue
changed = False
# The title is derived like every other field, and not syncing it left
# `cust-wp-0010` reading "Domain and Repository Goals" while its file
# said "Workplan Lifecycle Documentation" — a record correctly
# identified and correctly backed, describing the wrong work.
#
# An empty derived title is not an answer: three activity-core files
# parse to no title at all, and blanking a real one is worse than
# leaving it stale.
if w.title and w.title.strip() and row.title != w.title.strip():
row.title = w.title.strip()
changed = True
if w.description and getattr(row, "description", None) != w.description:
row.description = w.description
changed = True
if getattr(row, "owner", None) != w.owner:
row.owner = w.owner
changed = True
if w.status and row.status != w.status:
row.status = w.status
changed = True
if row.backing_relative_path != w.relative_path:
row.backing_relative_path = w.relative_path
row.backing_filename = w.relative_path.rsplit("/", 1)[-1]
row.backing_archived = w.archived
changed = True
if row.projection_retired_at is not None:
# It derives again; un-retire rather than leaving a contradiction.
row.projection_retired_at = None
row.projection_retired_reason = None
changed = True
if row.derived_from_commit != derived.commit:
row.derived_from_commit = derived.commit
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))
r.projection_retired_at = now
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
or outcome.created_tasks
or outcome.updated_tasks
or outcome.cancelled_tasks
):
outcome.status = "applied"
return outcome
# ---------------------------------------------------------------------------
# Fleet form (STATE-WP-0083-T04)
# ---------------------------------------------------------------------------
@dataclass
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] = {}
for r in self.results.values():
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) + 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", "released", "refused")
},
"results": self.results,
"errors": self.errors,
}
async def reset_fleet_projection(
session_factory: Any,
repo_slugs: list[str],
*,
acknowledge_retirements: bool = False,
forge_base: str = DEFAULT_FORGE_BASE,
) -> FleetResetOutcome:
"""Reset every repository, one at a time, sharing the per-repository path.
The fleet form is a loop over the repository form and nothing else
(`ADR-012` decision 7). The rarely-run wide operation must be the frequently
run narrow one, or the wide one is trusted on the strength of never having
been exercised.
A repository that refuses or errors is recorded and the pass continues.
Aborting on the first refusal would mean one unresolved repository blocks
reconstruction everywhere — which in practice means permanently.
Each repository gets its own session, so one failure cannot roll back
another's work or leave a poisoned transaction behind.
"""
outcome = FleetResetOutcome()
for slug in repo_slugs:
try:
async with session_factory() as session:
result = await reset_repository_projection(
session,
slug,
acknowledge_retirements=acknowledge_retirements,
forge_base=forge_base,
)
# A released identifier is a repair that stands on its own, so
# it must survive a refusal in the same repository — otherwise
# moving the release ahead of the refusal returns achieves
# nothing and the rollback quietly undoes it.
if result.status == "applied" or result.released:
await session.commit()
else:
await session.rollback()
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