feat: advance repository records and provenance
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
This commit is contained in:
parent
329af60753
commit
35e86d7b85
24 changed files with 1618 additions and 51 deletions
313
src/repo_manager/commands/record.py
Normal file
313
src/repo_manager/commands/record.py
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
"""Governed mutation commands for repository-owned intake and decision records."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from repo_manager import dual_run, idempotency
|
||||
from repo_manager.commands.workplan import CommandResult
|
||||
from repo_manager.gitops import GitError, commit_paths, head_sha, push_ff
|
||||
from repo_manager.index_store import append_event, default_index_path, save_index
|
||||
from repo_manager.observe import observe_repository
|
||||
from repo_manager.parse.record import iter_record_files, parse_record_file
|
||||
|
||||
_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
||||
_PROTECTED = frozenset(
|
||||
{
|
||||
"id",
|
||||
"kind",
|
||||
"title",
|
||||
"status",
|
||||
"notes",
|
||||
"created",
|
||||
"updated",
|
||||
"routed_at",
|
||||
"closed_at",
|
||||
"decided_at",
|
||||
}
|
||||
)
|
||||
_OPERATIONS = {
|
||||
"intake": frozenset({"create", "route", "note", "close"}),
|
||||
"decision": frozenset({"create", "update", "resolve"}),
|
||||
}
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def _reject(command: str, correlation_id: str, code: str, message: str, **evidence: Any) -> CommandResult:
|
||||
return CommandResult(
|
||||
command=command,
|
||||
status="rejected",
|
||||
correlation_id=correlation_id,
|
||||
evidence={"status": "rejected", **evidence},
|
||||
error={"code": code, "message": message, "correlation_id": correlation_id},
|
||||
)
|
||||
|
||||
|
||||
def _command(kind: str, operation: str) -> str:
|
||||
verbs = {
|
||||
("intake", "create"): "repo.work.create_intake",
|
||||
("intake", "route"): "repo.work.route_intake",
|
||||
("intake", "note"): "repo.work.add_intake_note",
|
||||
("intake", "close"): "repo.work.close_intake",
|
||||
("decision", "create"): "repo.work.create_decision",
|
||||
("decision", "update"): "repo.work.update_decision",
|
||||
("decision", "resolve"): "repo.work.resolve_decision",
|
||||
}
|
||||
return verbs.get((kind, operation), f"repo.work.{operation}_{kind}")
|
||||
|
||||
|
||||
def _default_path(repo_root: Path, kind: str) -> Path:
|
||||
return repo_root / f"{kind}s" / f"{kind}s.md"
|
||||
|
||||
|
||||
def _find(repo_root: Path, kind: str, record_id: str) -> list[tuple[Path, Any]]:
|
||||
found: list[tuple[Path, Any]] = []
|
||||
for path in iter_record_files(repo_root):
|
||||
for record in parse_record_file(path, repo_root=repo_root):
|
||||
if record.kind == kind and (record.id == record_id or record.uuid == record_id):
|
||||
found.append((path, record))
|
||||
return found
|
||||
|
||||
|
||||
def _dump_block(record: dict[str, Any]) -> str:
|
||||
return "```yaml\n" + yaml.safe_dump(record, sort_keys=False, allow_unicode=True).rstrip() + "\n```"
|
||||
|
||||
|
||||
def _write_new(path: Path, kind: str, record: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
previous = path.read_text(encoding="utf-8") if path.is_file() else f"# {kind.title()} records\n"
|
||||
text = previous.rstrip() + f"\n\n## {record['id']} — {record['title']}\n\n" + _dump_block(record) + "\n"
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def _replace(path: Path, record: Any, data: dict[str, Any]) -> None:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
updated = text[: record.block_start] + _dump_block(data) + text[record.block_end :]
|
||||
path.write_text(updated, encoding="utf-8")
|
||||
|
||||
|
||||
def mutate_record(
|
||||
repo_root: Path,
|
||||
kind: str,
|
||||
record_id: str,
|
||||
*,
|
||||
operation: str,
|
||||
title: str | None = None,
|
||||
status: str | None = None,
|
||||
data: dict[str, Any] | None = None,
|
||||
route_to: str | None = None,
|
||||
note: str | None = None,
|
||||
author: str | None = None,
|
||||
outcome: str | None = None,
|
||||
rationale: str | None = None,
|
||||
decided_by: str | None = None,
|
||||
correlation_id: str | None = None,
|
||||
reason: str = "rmgr command",
|
||||
commit: bool = True,
|
||||
push: bool = False,
|
||||
expected_head_sha: str | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
repo_slug: str | None = None,
|
||||
) -> CommandResult:
|
||||
"""Mutate one intake or decision YAML block in its authoritative Markdown file."""
|
||||
correlation_id = correlation_id or str(uuid.uuid4())
|
||||
command = _command(kind, operation)
|
||||
repo_root = repo_root.resolve()
|
||||
payload = {
|
||||
"repo_root": str(repo_root),
|
||||
"kind": kind,
|
||||
"record_id": record_id,
|
||||
"operation": operation,
|
||||
"title": title,
|
||||
"status": status,
|
||||
"data": data,
|
||||
"route_to": route_to,
|
||||
"note": note,
|
||||
"author": author,
|
||||
"outcome": outcome,
|
||||
"rationale": rationale,
|
||||
"decided_by": decided_by,
|
||||
"expected_head_sha": expected_head_sha,
|
||||
}
|
||||
digest = idempotency.payload_hash(payload)
|
||||
if idempotency_key:
|
||||
prior = idempotency.get(idempotency_key)
|
||||
if prior:
|
||||
if prior.get("payload_hash") != digest:
|
||||
return _reject(command, correlation_id, "conflict", "idempotency key reused")
|
||||
previous = prior.get("result") or {}
|
||||
return CommandResult(
|
||||
command=previous.get("command", command),
|
||||
status=previous.get("status", "applied"),
|
||||
correlation_id=previous.get("correlation_id", correlation_id),
|
||||
evidence=previous.get("evidence") or {"status": "applied", "replay": True},
|
||||
error=previous.get("error"),
|
||||
)
|
||||
|
||||
if kind not in _OPERATIONS or operation not in _OPERATIONS[kind]:
|
||||
return _reject(command, correlation_id, "validation_error", f"invalid {kind!r} operation {operation!r}")
|
||||
if not _ID_RE.fullmatch(record_id):
|
||||
return _reject(command, correlation_id, "validation_error", f"invalid record id {record_id!r}")
|
||||
if data is not None and not isinstance(data, dict):
|
||||
return _reject(command, correlation_id, "validation_error", "data must be an object")
|
||||
protected = _PROTECTED.intersection(data or {})
|
||||
if protected:
|
||||
return _reject(
|
||||
command,
|
||||
correlation_id,
|
||||
"validation_error",
|
||||
f"data contains protected fields: {', '.join(sorted(protected))}",
|
||||
)
|
||||
|
||||
current_head = head_sha(repo_root)
|
||||
if expected_head_sha and current_head and current_head != expected_head_sha:
|
||||
result = _reject(
|
||||
command,
|
||||
correlation_id,
|
||||
"precondition_failed",
|
||||
f"expected_head_sha {expected_head_sha} != {current_head}",
|
||||
head_sha=current_head,
|
||||
)
|
||||
if result.error:
|
||||
result.error["retryable"] = True
|
||||
return result
|
||||
|
||||
matches = _find(repo_root, kind, record_id)
|
||||
if len(matches) > 1:
|
||||
return _reject(command, correlation_id, "conflict", f"record {record_id!r} is duplicated")
|
||||
match = matches[0] if matches else None
|
||||
now = _now()
|
||||
|
||||
if operation == "create":
|
||||
if match:
|
||||
return _reject(command, correlation_id, "conflict", f"record {record_id!r} exists")
|
||||
if not title or not title.strip():
|
||||
return _reject(command, correlation_id, "validation_error", "create requires title")
|
||||
record_data: dict[str, Any] = {
|
||||
"id": record_id,
|
||||
"kind": kind,
|
||||
"title": title.strip(),
|
||||
"status": status or "open",
|
||||
**(data or {}),
|
||||
"created": now,
|
||||
"updated": now,
|
||||
}
|
||||
path = _default_path(repo_root, kind)
|
||||
_write_new(path, kind, record_data)
|
||||
else:
|
||||
if not match:
|
||||
return _reject(command, correlation_id, "not_found", f"record {record_id!r} not found")
|
||||
path, parsed = match
|
||||
record_data = dict(parsed.raw)
|
||||
if kind == "intake" and operation == "route":
|
||||
if not route_to or not route_to.strip():
|
||||
return _reject(command, correlation_id, "validation_error", "route requires route_to")
|
||||
record_data.update(status="routed", routed_to=route_to.strip(), routed_at=now)
|
||||
elif kind == "intake" and operation == "note":
|
||||
if not note or not note.strip():
|
||||
return _reject(command, correlation_id, "validation_error", "note requires content")
|
||||
record_data.setdefault("notes", []).append(
|
||||
{"content": note.strip(), "author": author or "codex", "created": now}
|
||||
)
|
||||
elif kind == "intake" and operation == "close":
|
||||
record_data.update(status="closed", closed_at=now)
|
||||
if outcome:
|
||||
record_data["outcome"] = outcome.strip()
|
||||
elif kind == "decision" and operation == "update":
|
||||
if title is None and status is None and not data:
|
||||
return _reject(command, correlation_id, "validation_error", "update requires a field")
|
||||
if title is not None:
|
||||
if not title.strip():
|
||||
return _reject(command, correlation_id, "validation_error", "title cannot be empty")
|
||||
record_data["title"] = title.strip()
|
||||
if status is not None:
|
||||
if not status.strip():
|
||||
return _reject(command, correlation_id, "validation_error", "status cannot be empty")
|
||||
record_data["status"] = status.strip()
|
||||
record_data.update(data or {})
|
||||
else:
|
||||
if not rationale or not rationale.strip() or not decided_by or not decided_by.strip():
|
||||
return _reject(
|
||||
command,
|
||||
correlation_id,
|
||||
"validation_error",
|
||||
"resolve requires rationale and decided_by",
|
||||
)
|
||||
record_data.update(
|
||||
status="resolved",
|
||||
rationale=rationale.strip(),
|
||||
decided_by=decided_by.strip(),
|
||||
decided_at=now,
|
||||
)
|
||||
record_data["updated"] = now
|
||||
_replace(path, parsed, record_data)
|
||||
|
||||
relative_path = str(path.relative_to(repo_root))
|
||||
git_sha: str | None = None
|
||||
push_ok: bool | None = None
|
||||
push_message: str | None = None
|
||||
if commit:
|
||||
try:
|
||||
git_sha = commit_paths(
|
||||
repo_root,
|
||||
[relative_path],
|
||||
message=(
|
||||
f"{command} {record_id}\n\n"
|
||||
f"correlation_id: {correlation_id}\nreason: {reason}\nsource: repo-manager\n"
|
||||
),
|
||||
)
|
||||
except GitError as exc:
|
||||
return CommandResult(
|
||||
command=command,
|
||||
status="failed",
|
||||
correlation_id=correlation_id,
|
||||
evidence={"status": "failed", "files_touched": [relative_path], "git_sha": None},
|
||||
error={"code": "internal", "message": str(exc), "correlation_id": correlation_id},
|
||||
)
|
||||
if push:
|
||||
push_ok, push_message = push_ff(repo_root)
|
||||
|
||||
snapshot, index = observe_repository(repo_root, slug=repo_slug)
|
||||
append_event(
|
||||
index,
|
||||
{
|
||||
"type": "repo.command.applied",
|
||||
"command": command,
|
||||
"operation": operation,
|
||||
"correlation_id": correlation_id,
|
||||
"kind": kind,
|
||||
"id": record_id,
|
||||
"git_sha": git_sha,
|
||||
"files_touched": [relative_path],
|
||||
"source": "repo-manager",
|
||||
},
|
||||
)
|
||||
save_index(index)
|
||||
dual_run.record_mutation(
|
||||
source="repo-manager",
|
||||
kind=f"{kind}_{operation}",
|
||||
repo_slug=repo_slug or snapshot.get("slug"),
|
||||
detail={"id": record_id, "git_sha": git_sha, "correlation_id": correlation_id},
|
||||
)
|
||||
evidence: dict[str, Any] = {
|
||||
"status": "applied",
|
||||
"git_sha": git_sha,
|
||||
"files_touched": [relative_path],
|
||||
"index_path": str(default_index_path(repo_root)),
|
||||
"source": "repo-manager",
|
||||
}
|
||||
if push:
|
||||
evidence.update(push_ok=push_ok, push_message=push_message)
|
||||
result = CommandResult(command=command, status="applied", correlation_id=correlation_id, evidence=evidence)
|
||||
if idempotency_key:
|
||||
idempotency.put(idempotency_key, digest, result.to_dict())
|
||||
return result
|
||||
|
|
@ -187,4 +187,6 @@ def scaffold_repository(
|
|||
except GitError as exc:
|
||||
return CommandResult("failed", evidence, {"message": str(exc)}, cid)
|
||||
|
||||
return CommandResult("applied" if written else "rejected", evidence, None, cid)
|
||||
if not written:
|
||||
evidence["noop"] = True
|
||||
return CommandResult("applied", evidence, None, cid)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue