feat(cache): add rebuild and provenance safeguards

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
This commit is contained in:
tegwick 2026-08-21 23:23:03 +02:00
parent bf3f416b82
commit 5dc2f3cdf6
11 changed files with 2809 additions and 4 deletions

156
src/repo_manager/cache.py Normal file
View file

@ -0,0 +1,156 @@
"""Rebuildable local-cache status and legacy provenance export."""
from __future__ import annotations
import hashlib
import json
from datetime import datetime
from pathlib import Path
from typing import Any
from urllib.request import urlopen
from repo_manager.gitops import head_sha
from repo_manager.index_store import default_index_path, load_index
from repo_manager.parse.record import iter_record_files
from repo_manager.parse.register import iter_register_files
from repo_manager.parse.workplan import iter_workplan_files
from repo_manager.time import utc_now, utc_now_text
CACHE_STATUS_SCHEMA = "repo-manager.cache-status.v1"
CLOSED_EXPORT_SCHEMA = "repo-manager.closed-workplan-provenance.v1"
def authoritative_source_files(repo_root: Path) -> list[Path]:
repo_root = repo_root.resolve()
paths = {
*iter_workplan_files(repo_root),
*iter_record_files(repo_root),
*iter_register_files(repo_root),
}
for name in (".repo-classification.yaml", "INTENT.md", "GOAL.md"):
path = repo_root / name
if path.is_file():
paths.add(path)
return sorted(paths, key=lambda path: str(path.relative_to(repo_root)))
def source_fingerprint(repo_root: Path) -> tuple[str, list[str]]:
"""Hash the paths and bytes that feed the local repository projection."""
repo_root = repo_root.resolve()
digest = hashlib.sha256()
relative_paths: list[str] = []
for path in authoritative_source_files(repo_root):
relative = str(path.relative_to(repo_root))
relative_paths.append(relative)
digest.update(relative.encode("utf-8"))
digest.update(b"\0")
digest.update(path.read_bytes())
digest.update(b"\0")
return digest.hexdigest(), relative_paths
def _parse_timestamp(value: str) -> datetime:
parsed = datetime.fromisoformat(value)
if parsed.tzinfo is None:
raise ValueError("cache observed_at must be timezone-aware")
return parsed
def cache_status(repo_root: Path) -> dict[str, Any]:
repo_root = repo_root.resolve()
path = default_index_path(repo_root)
checked_at = utc_now()
if not path.is_file():
return {
"schema": CACHE_STATUS_SCHEMA,
"ok": True,
"advisory": True,
"cache_present": False,
"stale": True,
"stale_reasons": ["cache_missing"],
"checked_at": checked_at.isoformat().replace("+00:00", "Z"),
"repo_root": str(repo_root),
"index_path": str(path),
}
index = load_index(path)
fingerprint, source_files = source_fingerprint(repo_root)
stale_reasons: list[str] = []
if not index.source_fingerprint:
stale_reasons.append("legacy_index_missing_source_fingerprint")
elif index.source_fingerprint != fingerprint:
stale_reasons.append("authoritative_source_changed")
try:
observed_at = _parse_timestamp(index.observed_at)
age_seconds = max(0, int((checked_at - observed_at).total_seconds()))
except (TypeError, ValueError):
observed_at = None
age_seconds = None
stale_reasons.append("invalid_observed_at")
return {
"schema": CACHE_STATUS_SCHEMA,
"ok": True,
"advisory": True,
"cache_present": True,
"stale": bool(stale_reasons),
"stale_reasons": stale_reasons,
"checked_at": checked_at.isoformat().replace("+00:00", "Z"),
"observed_at": index.observed_at,
"age_seconds": age_seconds,
"repo_root": str(repo_root),
"index_path": str(path),
"indexed_head_sha": index.head_sha,
"current_head_sha": head_sha(repo_root),
"source_fingerprint": index.source_fingerprint,
"current_source_fingerprint": fingerprint,
"source_file_count": len(source_files),
"record_count": len(index.work_records),
}
def build_closed_provenance_export(
workplans: list[dict[str, Any]],
repos: list[dict[str, Any]],
*,
source: str,
) -> dict[str, Any]:
repo_slugs = {str(repo["id"]): repo.get("slug") for repo in repos if repo.get("id")}
rows = [
{
"id": row.get("id"),
"repo_id": row.get("repo_id"),
"repo_slug": repo_slugs.get(str(row.get("repo_id"))),
"slug": row.get("slug"),
"title": row.get("title"),
"status": row.get("status"),
"owner": row.get("owner"),
"created_at": row.get("created_at"),
"updated_at": row.get("updated_at"),
}
for row in workplans
if row.get("status") in {"finished", "archived"} and not row.get("backing_filename")
]
rows.sort(key=lambda row: (str(row["status"]), str(row["repo_slug"]), str(row["slug"])))
canonical_rows = json.dumps(rows, sort_keys=True, separators=(",", ":")).encode("utf-8")
return {
"schema": CLOSED_EXPORT_SCHEMA,
"ok": True,
"generated_at": utc_now_text(),
"source": source,
"filter": "backing_filename is null and status in [finished, archived]",
"count": len(rows),
"rows_sha256": hashlib.sha256(canonical_rows).hexdigest(),
"rows": rows,
}
def fetch_closed_provenance(api_base: str) -> dict[str, Any]:
base = api_base.rstrip("/")
with urlopen(f"{base}/workplans/", timeout=30) as response:
workplans = json.load(response)
with urlopen(f"{base}/repos/?limit=500", timeout=30) as response:
repos = json.load(response)
if not isinstance(workplans, list) or not isinstance(repos, list):
raise TypeError("State Hub returned a non-list workplan or repository payload")
return build_closed_provenance_export(workplans, repos, source=base)

View file

@ -314,6 +314,24 @@ def main(argv: list[str] | None = None) -> int:
p_authority_route.add_argument("--domain-slug", default=None)
p_authority_route.add_argument("--claimed-owner", default=None)
p_cache = sub.add_parser("cache", help="Inspect and rebuild advisory repository caches")
cache_sub = p_cache.add_subparsers(dest="cache_command")
p_cache_status = cache_sub.add_parser("status", help="Report cache age and source drift")
p_cache_status.add_argument("--path", default=".")
p_cache_rebuild = cache_sub.add_parser("rebuild", help="Rebuild the local index from files")
p_cache_rebuild.add_argument("--path", default=".")
p_cache_rebuild.add_argument("--slug", default=None)
p_cache_export = cache_sub.add_parser(
"export-closed",
help="Export unbound closed State Hub records before cache replacement",
)
p_cache_export.add_argument(
"--api-base",
default=os.environ.get("STATE_HUB_API_BASE", "http://127.0.0.1:8000"),
)
p_cache_export.add_argument("--output", required=True)
p_cache_export.add_argument("--force", action="store_true")
args = parser.parse_args(argv)
if args.version or args.command in (None, "version"):
@ -687,6 +705,49 @@ def main(argv: list[str] | None = None) -> int:
print(json.dumps({"ok": True, **result}, indent=2))
return 0
if args.command == "cache":
if not args.cache_command:
p_cache.print_help()
return 2
from repo_manager.cache import cache_status, fetch_closed_provenance
if args.cache_command == "status":
result = cache_status(Path(args.path))
elif args.cache_command == "rebuild":
from repo_manager.index_store import save_index
from repo_manager.observe import observe_repository
snapshot, index = observe_repository(Path(args.path), slug=args.slug)
index_path = save_index(index)
result = {
"ok": True,
"rebuilt": True,
"index_path": str(index_path),
"record_count": snapshot["index"]["record_count"],
"cache": cache_status(Path(args.path)),
}
else:
output = Path(args.output)
if output.exists() and not args.force:
print(
json.dumps(
{"ok": False, "error": f"output exists: {output}; use --force to replace"},
indent=2,
)
)
return 1
try:
result = fetch_closed_provenance(args.api_base)
except (OSError, TypeError, ValueError) as exc:
print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
return 1
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
result = {key: value for key, value in result.items() if key != "rows"}
result["output"] = str(output)
print(json.dumps(result, indent=2))
return 0 if result.get("ok") else 1
parser.print_help()
return 0

View file

@ -3,6 +3,8 @@
from __future__ import annotations
import json
import os
import tempfile
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
@ -32,9 +34,11 @@ class RepoIndex:
repo_root: str
head_sha: str | None
observed_at: str
source_fingerprint: str | None = None
source_files: list[str] = field(default_factory=list)
work_records: list[WorkRecordEntry] = field(default_factory=list)
events: list[dict[str, Any]] = field(default_factory=list)
schema: str = "repo_manager.index.v0"
schema: str = "repo_manager.index.v1"
def to_dict(self) -> dict[str, Any]:
return {
@ -43,6 +47,8 @@ class RepoIndex:
"repo_root": self.repo_root,
"head_sha": self.head_sha,
"observed_at": self.observed_at,
"source_fingerprint": self.source_fingerprint,
"source_files": self.source_files,
"work_records": [asdict(r) for r in self.work_records],
"events": self.events,
}
@ -58,6 +64,8 @@ class RepoIndex:
repo_root=data["repo_root"],
head_sha=data.get("head_sha"),
observed_at=data.get("observed_at") or _now(),
source_fingerprint=data.get("source_fingerprint"),
source_files=list(data.get("source_files") or []),
work_records=records,
events=list(data.get("events") or []),
schema=data.get("schema") or "repo_manager.index.v0",
@ -71,7 +79,24 @@ def default_index_path(repo_root: Path) -> Path:
def save_index(index: RepoIndex, path: Path | None = None) -> Path:
path = path or default_index_path(Path(index.repo_root))
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(index.to_dict(), indent=2) + "\n", encoding="utf-8")
temporary: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=path.parent,
prefix=f".{path.name}.",
delete=False,
) as handle:
temporary = Path(handle.name)
handle.write(json.dumps(index.to_dict(), indent=2) + "\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
temporary = None
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
return path

View file

@ -7,6 +7,7 @@ from pathlib import Path
import yaml
from repo_manager.cache import source_fingerprint
from repo_manager.classification import require_valid_classification
from repo_manager.gitops import head_sha, is_git_repo
from repo_manager.index_store import RepoIndex, WorkRecordEntry, _now
@ -117,11 +118,14 @@ def observe_repository(repo_root: Path, *, slug: str | None = None) -> tuple[dic
)
sha = head_sha(repo_root) if is_git_repo(repo_root) else None
fingerprint, source_files = source_fingerprint(repo_root)
index = RepoIndex(
slug=slug,
repo_root=str(repo_root),
head_sha=sha,
observed_at=_now(),
source_fingerprint=fingerprint,
source_files=source_files,
work_records=records,
)