"""Governed mutation commands for repository-owned intake and decision records.""" from __future__ import annotations import re import uuid 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 from repo_manager.time import utc_now_text _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 utc_now_text() 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