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:
parent
bf3f416b82
commit
5dc2f3cdf6
11 changed files with 2809 additions and 4 deletions
156
src/repo_manager/cache.py
Normal file
156
src/repo_manager/cache.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue