Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
1304 lines
53 KiB
Python
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
|