diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 86db8fd..9279555 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -325,7 +325,7 @@ | task | STATE-WP-0085-T02 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | | task | STATE-WP-0085-T03 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | | task | STATE-WP-0085-T04 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | -| task | STATE-WP-0085-T05 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | +| task | STATE-WP-0085-T05 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | | task | STATE-WP-0085-T06 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | | task | STATE-WP-0085-T07 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | | task | STATE-WP-0085-T08 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | diff --git a/api/main.py b/api/main.py index aa2941c..4364def 100644 --- a/api/main.py +++ b/api/main.py @@ -111,6 +111,7 @@ app.include_router(recently_on_scope.router) app.include_router(consistency_sweep.router) app.include_router(repos.router) app.include_router(repository_renames.router) +app.include_router(repository_renames.operation_router) app.include_router(topics.router) app.include_router(workstreams.router) app.include_router(workstreams.workplan_router) diff --git a/api/routers/repository_renames.py b/api/routers/repository_renames.py index b52cc11..e6febe2 100644 --- a/api/routers/repository_renames.py +++ b/api/routers/repository_renames.py @@ -31,6 +31,7 @@ from api.services.repository_rename import ( create_operation, list_operations, load_operation, + load_operation_by_id, rollback_preflight, verify_forge_identity, verify_operation, @@ -38,6 +39,9 @@ from api.services.repository_rename import ( router = APIRouter(prefix="/repos/{repo_id}", tags=["repository-renames"]) +operation_router = APIRouter( + prefix="/repository-renames", tags=["repository-renames"] +) def _raise(exc: RenameLifecycleError) -> None: @@ -122,7 +126,22 @@ async def create_repository_rename_operation( gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway), ) -> dict[str, Any]: try: - operation = await create_operation(session, gateway, repo_id, body) + operation, no_op = await create_operation(session, gateway, repo_id, body) + except RenameLifecycleError as exc: + _raise(exc) + return _operation_read(operation, no_op=no_op) + + +@operation_router.get( + "/operations/{operation_id}", response_model=RepositoryRenameOperationRead +) +async def get_repository_rename_operation_by_id( + operation_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> dict[str, Any]: + """Resolve an operation journal without requiring its repository UUID.""" + try: + operation = await load_operation_by_id(session, operation_id) except RenameLifecycleError as exc: _raise(exc) return _operation_read(operation) diff --git a/api/schemas/repository_rename.py b/api/schemas/repository_rename.py index d5cc3a1..797de45 100644 --- a/api/schemas/repository_rename.py +++ b/api/schemas/repository_rename.py @@ -50,6 +50,7 @@ class ForgeIdentityVerifyRequest(BaseModel): class RepositoryRenameOperationCreate(BaseModel): + operation_id: uuid.UUID | None = None new_slug: str = Field(min_length=1, max_length=100, pattern=r"^[a-z0-9][a-z0-9-]*$") preflight_token: str confirmation: str diff --git a/api/services/repository_rename.py b/api/services/repository_rename.py index 1428c6f..ec9d832 100644 --- a/api/services/repository_rename.py +++ b/api/services/repository_rename.py @@ -695,7 +695,22 @@ async def create_operation( gateway: ForgeRepositoryGateway, repo_id: uuid.UUID, body: RepositoryRenameOperationCreate, -) -> RepositoryRenameOperation: +) -> tuple[RepositoryRenameOperation, bool]: + if body.operation_id is not None: + existing = await session.get(RepositoryRenameOperation, body.operation_id) + if existing is not None: + if ( + existing.repo_id != repo_id + or existing.new_slug != body.new_slug + or existing.actor != body.actor + or body.confirmation + != confirmation_for(repo_id, existing.old_slug, existing.new_slug) + ): + raise RenamePreconditionFailed( + "Repository rename operation ID is already bound to another request" + ) + return existing, True + token = _verify_preflight_token(body.preflight_token) if token.get("repo_id") != str(repo_id) or token.get("new_slug") != body.new_slug: raise RenamePreconditionFailed("Preflight token does not address this rename") @@ -730,6 +745,7 @@ async def create_operation( forge = preflight["current"]["forge"] now = utcnow() operation = RepositoryRenameOperation( + id=body.operation_id or uuid.uuid4(), repo_id=repo.id, forge_identity_id=identity.id, forge_identity_state="verified", @@ -777,10 +793,33 @@ async def create_operation( await session.commit() except IntegrityError as exc: await session.rollback() + if body.operation_id is not None: + existing = await session.get(RepositoryRenameOperation, body.operation_id) + if ( + existing is not None + and existing.repo_id == repo_id + and existing.new_slug == body.new_slug + and existing.actor == body.actor + and body.confirmation + == confirmation_for(repo_id, existing.old_slug, existing.new_slug) + ): + return existing, True raise RenamePreconditionFailed( "A conflicting repository rename operation was created" ) from exc await session.refresh(operation) + return operation, False + + +async def load_operation_by_id( + session: AsyncSession, + operation_id: uuid.UUID, +) -> RepositoryRenameOperation: + operation = await session.get(RepositoryRenameOperation, operation_id) + if operation is None: + raise RenameNotFound( + f"Repository rename operation {operation_id} was not found" + ) return operation diff --git a/custodian_cli.py b/custodian_cli.py index 6ac1c8b..4c600bf 100644 --- a/custodian_cli.py +++ b/custodian_cli.py @@ -22,6 +22,7 @@ import urllib.parse import urllib.request from pathlib import Path +from repository_rename_cli import configure_repo_commands from statehub_register import run_register as run_statehub_register STATE_HUB_DIR = Path(__file__).resolve().parent @@ -728,6 +729,8 @@ def main() -> None: ) sub = parser.add_subparsers(dest="command", required=True) + configure_repo_commands(sub) + # register statehub_reg = sub.add_parser( "register", diff --git a/pyproject.toml b/pyproject.toml index 44a18ec..2f62735 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ build-backend = "hatchling.build" packages = ["api", "mcp_server", "task_flow_engine"] artifacts = [ "custodian_cli.py", + "repository_rename_cli.py", "statehub_register.py", "scripts/consistency_check.py", "scripts/repo_sync.py", @@ -41,6 +42,17 @@ artifacts = [ "scripts/project_rules/*.template", ] +[tool.hatch.build.targets.wheel.force-include] +"custodian_cli.py" = "custodian_cli.py" +"repository_rename_cli.py" = "repository_rename_cli.py" +"statehub_register.py" = "statehub_register.py" +"scripts/consistency_check.py" = "scripts/consistency_check.py" +"scripts/ensure_gitignore_claude_rules.py" = "scripts/ensure_gitignore_claude_rules.py" +"scripts/repo_sync.py" = "scripts/repo_sync.py" +"scripts/mcp_registration.py" = "scripts/mcp_registration.py" +"scripts/project_claude_md.template" = "scripts/project_claude_md.template" +"scripts/project_rules" = "scripts/project_rules" + [tool.uv.sources] llm-connect = { path = "/home/worsch/llm-connect", editable = true } hub-core = { path = "/home/worsch/hub-core", editable = true } diff --git a/repository_rename_cli.py b/repository_rename_cli.py new file mode 100644 index 0000000..e96f924 --- /dev/null +++ b/repository_rename_cli.py @@ -0,0 +1,835 @@ +"""Non-interactive State Hub repository-rename orchestration CLI.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shlex +import stat +import sys +import urllib.error +import urllib.parse +import urllib.request +import uuid +from pathlib import Path +from typing import Any, Callable + + +CLI_SCHEMA_VERSION = "state-hub.repository-rename-cli.v1" +DEFAULT_API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000") +FORWARD_PHASES = ( + "forge-renamed", + "statehub-rebound", + "source-synced", + "consumers-verified", + "completed", +) +NEXT_PHASE = { + "preflighted": "forge-renamed", + "forge-renamed": "statehub-rebound", + "statehub-rebound": "source-synced", + "source-synced": "consumers-verified", + "consumers-verified": "completed", +} +_HEADERS = {"X-StateHub-Component": "state-hub.repository-rename-cli"} +_SENSITIVE_KEYS = { + "authorization", + "cookie", + "set-cookie", + "password", + "secret", + "api-key", + "api_key", + "access-token", + "access_token", + "refresh-token", + "refresh_token", + "credential", +} + + +class RenameCLIError(RuntimeError): + def __init__( + self, + message: str, + *, + code: str = "rename_cli_error", + status_code: int | None = None, + details: Any = None, + ) -> None: + super().__init__(message) + self.code = code + self.status_code = status_code + self.details = details + + +def _redact_text(value: str) -> str: + value = re.sub( + r"(https?://)[^/@\s]+:[^/@\s]+@", + r"\1[REDACTED]@", + value, + flags=re.IGNORECASE, + ) + value = re.sub( + r"\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+", + r"\1 [REDACTED]", + value, + flags=re.IGNORECASE, + ) + value = re.sub( + r"([?&][^=&#\s]*(?:api[_-]?key|password|secret|signature|token|credential)" + r"[^=&#\s]*=)[^&#\s]+", + r"\1[REDACTED]", + value, + flags=re.IGNORECASE, + ) + return value + + +def _public(value: Any, *, redact_preflight_token: bool = True) -> Any: + if isinstance(value, dict): + result: dict[str, Any] = {} + for key, item in value.items(): + normalized = key.lower() + if normalized in _SENSITIVE_KEYS or any( + marker in normalized + for marker in ("password", "authorization", "credential") + ): + result[key] = "[REDACTED]" + elif key == "preflight_token" and redact_preflight_token and item: + result[key] = "[REDACTED: use --output preflight file]" + else: + result[key] = _public( + item, redact_preflight_token=redact_preflight_token + ) + return result + if isinstance(value, list): + return [ + _public(item, redact_preflight_token=redact_preflight_token) + for item in value + ] + if isinstance(value, str): + return _redact_text(value) + return value + + +def _api_request( + api_base: str, + method: str, + path: str, + body: dict[str, Any] | None = None, +) -> dict[str, Any]: + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = dict(_HEADERS) + if data is not None: + headers["Content-Type"] = "application/json" + request = urllib.request.Request( + api_base.rstrip("/") + path, + data=data, + headers=headers, + method=method, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + payload = json.loads(response.read()) + except urllib.error.HTTPError as exc: + try: + payload = json.loads(exc.read()) + except (json.JSONDecodeError, UnicodeDecodeError): + payload = {} + detail = payload.get("detail", payload) if isinstance(payload, dict) else {} + if isinstance(detail, dict): + message = str(detail.get("message") or f"State Hub returned HTTP {exc.code}") + code = str(detail.get("code") or "state_hub_http_error") + details = detail.get("details") + else: + message = str(detail or f"State Hub returned HTTP {exc.code}") + code = "state_hub_http_error" + details = None + raise RenameCLIError( + _redact_text(message), + code=code, + status_code=exc.code, + details=_public(details), + ) from exc + except (urllib.error.URLError, TimeoutError) as exc: + raise RenameCLIError( + "State Hub is unavailable", + code="state_hub_unavailable", + details={"reason": _redact_text(str(exc.reason if hasattr(exc, "reason") else exc))}, + ) from exc + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise RenameCLIError( + "State Hub returned an invalid JSON response", + code="invalid_state_hub_response", + ) from exc + if not isinstance(payload, dict): + raise RenameCLIError( + "State Hub returned a non-object response", + code="invalid_state_hub_response", + ) + return payload + + +def _load_json(path: str, *, expected: type | tuple[type, ...]) -> Any: + try: + if path == "-": + value = json.load(sys.stdin) + else: + target = Path(path) + if target.stat().st_mode & (stat.S_IRWXG | stat.S_IRWXO): + raise RenameCLIError( + f"Refusing non-private input file {target}; require mode 0600", + code="unsafe_input_permissions", + ) + value = json.loads(target.read_text(encoding="utf-8")) + except RenameCLIError: + raise + except (OSError, json.JSONDecodeError) as exc: + raise RenameCLIError( + f"Could not read JSON input {path}", + code="invalid_json_input", + details={"reason": _redact_text(str(exc))}, + ) from exc + if not isinstance(value, expected): + names = ( + ", ".join(item.__name__ for item in expected) + if isinstance(expected, tuple) + else expected.__name__ + ) + raise RenameCLIError( + f"JSON input {path} must contain {names}", code="invalid_json_input" + ) + return value + + +def _write_private_json(path: str, payload: dict[str, Any]) -> None: + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + try: + descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + except FileExistsError as exc: + raise RenameCLIError( + f"Refusing to overwrite existing preflight file {target}", + code="output_exists", + ) from exc + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + handle.write("\n") + except Exception: + target.unlink(missing_ok=True) + raise + + +def _confirmation(repo_id: str, old_slug: str, new_slug: str) -> str: + return f"rename:{repo_id}:{old_slug}:{new_slug}" + + +def _rollback_confirmation(operation_id: str) -> str: + return f"rollback:{operation_id}" + + +def _validated_uuid(value: str, *, field: str = "operation ID") -> str: + try: + return str(uuid.UUID(value)) + except (AttributeError, TypeError, ValueError) as exc: + raise RenameCLIError( + f"{field} must be a UUID", + code="invalid_operation_id", + ) from exc + + +def _command(*parts: str | None) -> list[str]: + return [part for part in parts if part is not None] + + +def _next_for_operation(operation: dict[str, Any]) -> dict[str, Any]: + operation_id = str(operation["id"]) + repo_id = str(operation["repo_id"]) + phase = str(operation["phase"]) + confirmation = _confirmation( + repo_id, str(operation["old_slug"]), str(operation["new_slug"]) + ) + if phase in NEXT_PHASE: + target = NEXT_PHASE[phase] + command = _command( + "statehub", + "repo", + "rename", + "apply", + operation_id, + "--phase", + target, + "--confirm", + confirmation, + ) + if target in {"source-synced", "consumers-verified"}: + command.extend(["--evidence-file", ""]) + if target == "consumers-verified": + command.extend(["--checks-file", ""]) + return { + "action": "apply-phase", + "phase": target, + "expected_phase": phase, + "requires_confirmation": True, + "requires_evidence": target in {"source-synced", "consumers-verified"}, + "requires_checks": target == "consumers-verified", + "command": command, + } + if phase == "rollback-preflight": + return { + "action": "execute-rollback", + "requires_confirmation": True, + "command": [ + "statehub", + "repo", + "rename", + "rollback", + operation_id, + "--confirm", + _rollback_confirmation(operation_id), + "--execute", + ], + } + if phase == "completed": + return {"action": "none", "reason": "rename completed", "command": None} + if phase == "rolled-back": + return {"action": "none", "reason": "rename rolled back", "command": None} + return { + "action": "inspect", + "reason": f"unsupported or unknown phase {phase!r}", + "command": ["statehub", "repo", "rename", "status", operation_id], + } + + +def _state_for_operation(operation: dict[str, Any]) -> str: + if operation.get("phase") == "rolled-back": + return "rolled-back" + if operation.get("error_code"): + return "failed" + if operation.get("phase") == "completed": + return "achieved" + return "planned" + + +def _envelope( + command: str, + *, + state: str, + ok: bool, + result: dict[str, Any] | None = None, + operation_id: str | None = None, + repo_id: str | None = None, + phase: str | None = None, + next_safe_action: dict[str, Any] | None = None, + error: dict[str, Any] | None = None, +) -> dict[str, Any]: + return { + "schema_version": CLI_SCHEMA_VERSION, + "command": command, + "ok": ok, + "state": state, + "operation_id": operation_id, + "repo_id": repo_id, + "phase": phase, + "next_safe_action": next_safe_action, + "result": result, + "error": error, + } + + +def _emit(payload: dict[str, Any], *, as_json: bool) -> None: + public = _public(payload) + if as_json: + print(json.dumps(public, indent=2, sort_keys=True)) + return + state = str(public["state"]).upper() + stream = sys.stderr if not public["ok"] else sys.stdout + print(f"{state}: repository rename {public['command']}", file=stream) + if public.get("operation_id"): + print(f" operation: {public['operation_id']}", file=stream) + if public.get("repo_id"): + print(f" repository: {public['repo_id']}", file=stream) + if public.get("phase"): + print(f" phase: {public['phase']}", file=stream) + result = public.get("result") or {} + if public["command"] == "preflight": + print(f" safe to apply: {bool(result.get('safe_to_apply'))}", file=stream) + print(f" blockers: {len(result.get('blockers') or [])}", file=stream) + if result.get("preflight_file"): + print(f" private preflight: {result['preflight_file']}", file=stream) + error = public.get("error") + if error: + print(f" error: {error.get('message')}", file=stream) + action = public.get("next_safe_action") or {} + if action.get("action"): + print(f" next safe action: {action['action']}", file=stream) + if action.get("command"): + print(f" command: {shlex.join(action['command'])}", file=stream) + + +def _run(args: argparse.Namespace, action: Callable[[], dict[str, Any]]) -> None: + try: + payload = action() + except RenameCLIError as exc: + payload = _envelope( + args.rename_command, + state="failed", + ok=False, + operation_id=getattr(args, "operation_id", None), + error={ + "code": exc.code, + "message": str(exc), + "status_code": exc.status_code, + "details": exc.details, + }, + next_safe_action={"action": "inspect-input-or-status", "command": None}, + ) + _emit(payload, as_json=args.as_json) + raise SystemExit(1) from exc + _emit(payload, as_json=args.as_json) + if not payload["ok"]: + raise SystemExit(2) + + +def _resolve_repo(args: argparse.Namespace, slug: str) -> dict[str, Any]: + quoted = urllib.parse.quote(slug, safe="") + repo = _api_request(args.api_base, "GET", f"/repos/{quoted}") + if repo.get("slug_status") == "alias": + raise RenameCLIError( + f"{slug!r} is a prior alias; current canonical slug is {repo.get('canonical_slug')!r}", + code="old_slug_is_alias", + ) + return repo + + +def cmd_preflight(args: argparse.Namespace) -> None: + def action() -> dict[str, Any]: + operation_id = ( + _validated_uuid(args.operation_id) + if args.operation_id + else str(uuid.uuid4()) + ) + if args.output and Path(args.output).exists(): + raise RenameCLIError( + f"Refusing to overwrite existing preflight file {args.output}", + code="output_exists", + ) + repo = _resolve_repo(args, args.old_slug) + edge_writes: list[dict[str, Any]] = [] + if args.queued_edge_writes: + edge_writes = _load_json(args.queued_edge_writes, expected=list) + if not all(isinstance(item, dict) for item in edge_writes): + raise RenameCLIError( + "Queued edge-write input must be a list of objects", + code="invalid_json_input", + ) + report = _api_request( + args.api_base, + "POST", + f"/repos/{repo['id']}/rename/preflight", + {"new_slug": args.new_slug, "queued_edge_writes": edge_writes}, + ) + confirmation = _confirmation(str(repo["id"]), args.old_slug, args.new_slug) + private = _envelope( + "preflight", + state="planned", + ok=bool(report.get("safe_to_apply")), + result=report, + operation_id=operation_id, + repo_id=str(repo["id"]), + phase=None, + ) + if args.output: + _write_private_json(args.output, private) + if report.get("safe_to_apply") and args.output: + next_action = { + "action": "start-operation", + "requires_confirmation": True, + "command": [ + "statehub", "repo", "rename", "start", + args.old_slug, args.new_slug, + "--operation-id", operation_id, + "--preflight-file", args.output, + "--actor", "", + "--confirm", confirmation, + ], + } + elif report.get("safe_to_apply"): + next_action = { + "action": "save-private-preflight", + "requires_confirmation": False, + "command": [ + "statehub", "repo", "rename", "preflight", + args.old_slug, args.new_slug, + "--operation-id", operation_id, + "--output", "", + ], + } + else: + next_action = { + "action": "resolve-blockers", + "requires_confirmation": False, + "command": None, + } + public_report = dict(report) + public_report["preflight_file"] = args.output + return _envelope( + "preflight", + state="planned" if report.get("safe_to_apply") else "failed", + ok=bool(report.get("safe_to_apply")), + result=public_report, + operation_id=operation_id, + repo_id=str(repo["id"]), + next_safe_action=next_action, + ) + + _run(args, action) + + +def cmd_start(args: argparse.Namespace) -> None: + def action() -> dict[str, Any]: + operation_id = _validated_uuid(args.operation_id) + try: + existing = _api_request( + args.api_base, + "GET", + f"/repository-renames/operations/{operation_id}", + ) + except RenameCLIError as exc: + if exc.status_code != 404: + raise + else: + expected_confirmation = _confirmation( + str(existing["repo_id"]), + str(existing["old_slug"]), + str(existing["new_slug"]), + ) + if ( + existing.get("old_slug") != args.old_slug + or existing.get("new_slug") != args.new_slug + or existing.get("actor") != args.actor + or args.confirm != expected_confirmation + ): + raise RenameCLIError( + "Operation ID is already bound to another repository rename", + code="operation_id_conflict", + ) + existing = {**existing, "no_op": True} + return _operation_envelope("start", existing) + + wrapper = _load_json(args.preflight_file, expected=dict) + if ( + wrapper.get("schema_version") != CLI_SCHEMA_VERSION + or wrapper.get("command") != "preflight" + ): + raise RenameCLIError( + "Preflight file is not a repository-rename CLI preflight", + code="invalid_preflight_file", + ) + if str(wrapper.get("operation_id")) != operation_id: + raise RenameCLIError( + "Preflight operation ID does not match --operation-id", + code="invalid_preflight_file", + ) + report = wrapper.get("result") or {} + repo = _resolve_repo(args, args.old_slug) + if ( + str(wrapper.get("repo_id")) != str(repo["id"]) + or report.get("old_slug") != args.old_slug + or report.get("new_slug") != args.new_slug + or not report.get("safe_to_apply") + or not report.get("preflight_token") + ): + raise RenameCLIError( + "Preflight file does not authorize this repository rename", + code="invalid_preflight_file", + ) + expected_confirmation = _confirmation( + str(repo["id"]), args.old_slug, args.new_slug + ) + if args.confirm != expected_confirmation: + raise RenameCLIError( + "Explicit repository rename confirmation is incorrect", + code="confirmation_mismatch", + ) + operation = _api_request( + args.api_base, + "POST", + f"/repos/{repo['id']}/rename/operations", + { + "operation_id": operation_id, + "new_slug": args.new_slug, + "preflight_token": report["preflight_token"], + "confirmation": args.confirm, + "actor": args.actor, + "queued_edge_writes": report.get("queued_edge_writes") or [], + }, + ) + return _operation_envelope("start", operation) + + _run(args, action) + + +def _load_operation(args: argparse.Namespace) -> dict[str, Any]: + operation_id = _validated_uuid(args.operation_id) + return _api_request( + args.api_base, + "GET", + f"/repository-renames/operations/{operation_id}", + ) + + +def _operation_envelope(command: str, operation: dict[str, Any]) -> dict[str, Any]: + return _envelope( + command, + state=_state_for_operation(operation), + ok=not bool(operation.get("error_code")), + result=operation, + operation_id=str(operation["id"]), + repo_id=str(operation["repo_id"]), + phase=str(operation["phase"]), + next_safe_action=_next_for_operation(operation), + ) + + +def cmd_status(args: argparse.Namespace) -> None: + _run(args, lambda: _operation_envelope("status", _load_operation(args))) + + +def cmd_apply(args: argparse.Namespace) -> None: + def action() -> dict[str, Any]: + operation = _load_operation(args) + expected_confirmation = _confirmation( + str(operation["repo_id"]), + str(operation["old_slug"]), + str(operation["new_slug"]), + ) + if args.confirm != expected_confirmation: + raise RenameCLIError( + "Explicit repository rename confirmation is incorrect", + code="confirmation_mismatch", + ) + evidence = ( + _load_json(args.evidence_file, expected=dict) + if args.evidence_file + else {} + ) + checks = ( + _load_json(args.checks_file, expected=dict) + if args.checks_file + else {} + ) + if any(not isinstance(value, bool) for value in checks.values()): + raise RenameCLIError( + "Checks file values must all be booleans", + code="invalid_json_input", + ) + updated = _api_request( + args.api_base, + "POST", + f"/repos/{operation['repo_id']}/rename/operations/{operation['id']}/phases/{args.phase}", + { + "expected_phase": args.expected_phase or operation["phase"], + "confirmation": args.confirm, + "checks": checks, + "evidence": evidence, + }, + ) + return _operation_envelope("apply", updated) + + _run(args, action) + + +def cmd_verify(args: argparse.Namespace) -> None: + def action() -> dict[str, Any]: + operation = _load_operation(args) + verification = _api_request( + args.api_base, + "GET", + f"/repos/{operation['repo_id']}/rename/operations/{operation['id']}/verify", + ) + if verification.get("ok"): + next_action = _next_for_operation(operation) + state = "achieved" + else: + next_action = { + "action": "resolve-verification-failures", + "command": [ + "statehub", "repo", "rename", "status", str(operation["id"]) + ], + } + state = "failed" + return _envelope( + "verify", + state=state, + ok=bool(verification.get("ok")), + result=verification, + operation_id=str(operation["id"]), + repo_id=str(operation["repo_id"]), + phase=str(operation["phase"]), + next_safe_action=next_action, + ) + + _run(args, action) + + +def cmd_rollback(args: argparse.Namespace) -> None: + def action() -> dict[str, Any]: + operation = _load_operation(args) + operation_id = str(operation["id"]) + expected_confirmation = _rollback_confirmation(operation_id) + if args.confirm != expected_confirmation: + raise RenameCLIError( + "Explicit rollback confirmation is incorrect", + code="confirmation_mismatch", + ) + if operation["phase"] == "rolled-back": + return _operation_envelope("rollback", operation) + if operation["phase"] != "rollback-preflight": + preflight = _api_request( + args.api_base, + "POST", + f"/repos/{operation['repo_id']}/rename/operations/{operation_id}/rollback-preflight", + { + "expected_phase": args.expected_phase or operation["phase"], + "confirmation": args.confirm, + }, + ) + operation = preflight["operation"] + if not preflight.get("safe_to_rollback"): + raise RenameCLIError( + "Repository rename rollback is unsafe", + code="rollback_unsafe", + details=preflight, + ) + if not args.execute: + return _operation_envelope("rollback", operation) + rolled_back = _api_request( + args.api_base, + "POST", + f"/repos/{operation['repo_id']}/rename/operations/{operation_id}/rollback", + {"expected_phase": "rollback-preflight", "confirmation": args.confirm}, + ) + return _operation_envelope("rollback", rolled_back) + + _run(args, action) + + +def _common(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--api-base", default=DEFAULT_API_BASE, help="State Hub API base URL" + ) + parser.add_argument( + "--json", + action="store_true", + dest="as_json", + help="Emit the stable orchestration JSON contract", + ) + + +def configure_repo_commands(subparsers: argparse._SubParsersAction) -> None: + repo = subparsers.add_parser("repo", help="Repository lifecycle commands") + repo_sub = repo.add_subparsers(dest="repo_command", required=True) + rename = repo_sub.add_parser( + "rename", help="Governed repository rename lifecycle" + ) + rename_sub = rename.add_subparsers(dest="rename_command", required=True) + + preflight = rename_sub.add_parser( + "preflight", + help="Inspect rename safety without mutating State Hub or Forgejo", + ) + preflight.add_argument("old_slug") + preflight.add_argument("new_slug") + preflight.add_argument( + "--operation-id", + default=None, + help="Client-owned UUID; generated when omitted", + ) + preflight.add_argument( + "--queued-edge-writes", + default=None, + help="Mode-0600 JSON list of queued-write evidence", + ) + preflight.add_argument( + "--output", + default=None, + help="Write the token-bearing preflight envelope to a new mode-0600 file", + ) + _common(preflight) + preflight.set_defaults(func=cmd_preflight) + + start = rename_sub.add_parser( + "start", + help="Create an idempotent operation journal from a private preflight file", + ) + start.add_argument("old_slug") + start.add_argument("new_slug") + start.add_argument("--operation-id", required=True) + start.add_argument( + "--preflight-file", + required=True, + help="Mode-0600 preflight file, or - for stdin", + ) + start.add_argument("--actor", required=True) + start.add_argument("--confirm", required=True, metavar="CONFIRMATION") + _common(start) + start.set_defaults(func=cmd_start) + + apply = rename_sub.add_parser( + "apply", help="Apply exactly one compare-and-set lifecycle phase" + ) + apply.add_argument("operation_id") + apply.add_argument("--phase", required=True, choices=FORWARD_PHASES) + apply.add_argument("--expected-phase", default=None) + apply.add_argument("--confirm", required=True, metavar="CONFIRMATION") + apply.add_argument( + "--evidence-file", + default=None, + help="Mode-0600 JSON object, or - for stdin", + ) + apply.add_argument( + "--checks-file", default=None, help="Mode-0600 JSON boolean map" + ) + _common(apply) + apply.set_defaults(func=cmd_apply) + + status_parser = rename_sub.add_parser( + "status", help="Read the durable operation journal" + ) + status_parser.add_argument("operation_id") + _common(status_parser) + status_parser.set_defaults(func=cmd_status) + + verify = rename_sub.add_parser( + "verify", help="Verify Forge identity and State Hub continuity" + ) + verify.add_argument("operation_id") + _common(verify) + verify.set_defaults(func=cmd_verify) + + rollback = rename_sub.add_parser( + "rollback", + help="Preflight rollback; --execute is required to perform it", + ) + rollback.add_argument("operation_id") + rollback.add_argument("--expected-phase", default=None) + rollback.add_argument("--confirm", required=True, metavar="CONFIRMATION") + rollback.add_argument( + "--execute", + action="store_true", + help="Execute after rollback preflight succeeds", + ) + _common(rollback) + rollback.set_defaults(func=cmd_rollback) + + +__all__ = [ + "CLI_SCHEMA_VERSION", + "RenameCLIError", + "configure_repo_commands", +] diff --git a/tests/test_repository_rename_api.py b/tests/test_repository_rename_api.py index 3831bab..5d6d3ae 100644 --- a/tests/test_repository_rename_api.py +++ b/tests/test_repository_rename_api.py @@ -154,6 +154,55 @@ async def test_dry_run_has_no_persistent_changes(client, test_engine, rename_set assert after == before +@pytest.mark.asyncio +async def test_client_operation_id_is_idempotent_and_globally_discoverable( + client, rename_setup +): + repo, _forge = rename_setup + preflight = await _preflight(client, repo["id"]) + operation_id = str(uuid.uuid4()) + confirmation = f"rename:{repo['id']}:flex-auth:access-engine" + payload = { + "operation_id": operation_id, + "new_slug": "access-engine", + "preflight_token": preflight["preflight_token"], + "confirmation": confirmation, + "actor": "helixforge-test", + } + + created = await client.post( + f"/repos/{repo['id']}/rename/operations", json=payload + ) + replay = await client.post( + f"/repos/{repo['id']}/rename/operations", + json={**payload, "preflight_token": "expired-after-creation"}, + ) + assert created.status_code == replay.status_code == 201 + assert created.json()["id"] == replay.json()["id"] == operation_id + assert created.json()["no_op"] is False + assert replay.json()["no_op"] is True + + discovered = await client.get( + f"/repository-renames/operations/{operation_id}" + ) + assert discovered.status_code == 200 + assert discovered.json()["repo_id"] == repo["id"] + assert discovered.json()["phase"] == "preflighted" + + collision = await client.post( + f"/repos/{repo['id']}/rename/operations", + json={**payload, "new_slug": "another-name"}, + ) + assert collision.status_code == 412 + assert collision.json()["detail"]["code"] == "repository_rename_precondition_failed" + + actor_collision = await client.post( + f"/repos/{repo['id']}/rename/operations", + json={**payload, "actor": "different-actor"}, + ) + assert actor_collision.status_code == 412 + + @pytest.mark.asyncio async def test_interrupt_resume_every_phase_and_preserve_uuid(client, rename_setup): repo, forge = rename_setup diff --git a/tests/test_repository_rename_cli.py b/tests/test_repository_rename_cli.py new file mode 100644 index 0000000..86c307e --- /dev/null +++ b/tests/test_repository_rename_cli.py @@ -0,0 +1,390 @@ +from __future__ import annotations + +import json +import stat +import sys +import uuid + +import pytest + +import custodian_cli +import repository_rename_cli as rename_cli + + +REPO_ID = "11111111-1111-4111-8111-111111111111" +OPERATION_ID = "22222222-2222-4222-8222-222222222222" +CONFIRMATION = f"rename:{REPO_ID}:flex-auth:access-engine" + + +def _repo() -> dict: + return { + "id": REPO_ID, + "slug": "flex-auth", + "canonical_slug": "flex-auth", + "requested_slug": "flex-auth", + "slug_status": "canonical", + } + + +def _operation(phase: str, *, no_op: bool = False) -> dict: + return { + "id": OPERATION_ID, + "repo_id": REPO_ID, + "phase": phase, + "old_slug": "flex-auth", + "new_slug": "access-engine", + "actor": "helixforge", + "error_code": None, + "error_message": None, + "no_op": no_op, + } + + +def _preflight() -> dict: + return { + "schema_version": "state-hub.repository-rename-preflight.v1", + "repo_id": REPO_ID, + "old_slug": "flex-auth", + "new_slug": "access-engine", + "safe_to_apply": True, + "blockers": [], + "warnings": [], + "queued_edge_writes": [], + "preflight_token": "signed-preflight-token", + } + + +def _run(monkeypatch, *args: str) -> None: + monkeypatch.setattr(sys, "argv", ["statehub", *args]) + custodian_cli.main() + + +def test_preflight_writes_private_token_file_and_redacts_stdout( + monkeypatch, tmp_path, capsys +): + calls = [] + + def request(_api_base, method, path, body=None): + calls.append((method, path, body)) + if method == "GET": + return _repo() + return _preflight() + + monkeypatch.setattr(rename_cli, "_api_request", request) + output = tmp_path / "preflight.json" + _run( + monkeypatch, + "repo", "rename", "preflight", "flex-auth", "access-engine", + "--operation-id", OPERATION_ID, + "--output", str(output), + "--json", + ) + + public = json.loads(capsys.readouterr().out) + private = json.loads(output.read_text()) + assert public["schema_version"] == rename_cli.CLI_SCHEMA_VERSION + assert public["operation_id"] == OPERATION_ID + assert public["next_safe_action"]["action"] == "start-operation" + assert public["result"]["preflight_token"].startswith("[REDACTED") + assert "signed-preflight-token" not in json.dumps(public) + assert private["result"]["preflight_token"] == "signed-preflight-token" + assert stat.S_IMODE(output.stat().st_mode) == 0o600 + assert calls[1][2] == { + "new_slug": "access-engine", + "queued_edge_writes": [], + } + + +def test_start_uses_client_operation_id_and_retry_safe_api_body( + monkeypatch, tmp_path, capsys +): + preflight_file = tmp_path / "preflight.json" + preflight_file.write_text( + json.dumps( + { + "schema_version": rename_cli.CLI_SCHEMA_VERSION, + "command": "preflight", + "operation_id": OPERATION_ID, + "repo_id": REPO_ID, + "result": _preflight(), + } + ) + ) + preflight_file.chmod(0o600) + calls = [] + + def request(_api_base, method, path, body=None): + calls.append((method, path, body)) + if path.startswith("/repository-renames/"): + raise rename_cli.RenameCLIError( + "not found", code="repository_rename_not_found", status_code=404 + ) + return _repo() if method == "GET" else _operation("preflighted") + + monkeypatch.setattr(rename_cli, "_api_request", request) + _run( + monkeypatch, + "repo", "rename", "start", "flex-auth", "access-engine", + "--operation-id", OPERATION_ID, + "--preflight-file", str(preflight_file), + "--actor", "helixforge", + "--confirm", CONFIRMATION, + "--json", + ) + + result = json.loads(capsys.readouterr().out) + assert result["phase"] == "preflighted" + assert result["next_safe_action"]["phase"] == "forge-renamed" + create_body = calls[2][2] + assert create_body["operation_id"] == OPERATION_ID + assert create_body["preflight_token"] == "signed-preflight-token" + assert create_body["actor"] == "helixforge" + assert "signed-preflight-token" not in json.dumps(result) + + +def test_start_retry_uses_operation_journal_after_old_slug_becomes_alias( + monkeypatch, capsys +): + calls = [] + + def request(_api_base, method, path, body=None): + calls.append((method, path, body)) + return _operation("statehub-rebound") + + monkeypatch.setattr(rename_cli, "_api_request", request) + _run( + monkeypatch, + "repo", + "rename", + "start", + "flex-auth", + "access-engine", + "--operation-id", + OPERATION_ID, + "--preflight-file", + "/already-consumed/preflight.json", + "--actor", + "helixforge", + "--confirm", + CONFIRMATION, + "--json", + ) + + result = json.loads(capsys.readouterr().out) + assert result["phase"] == "statehub-rebound" + assert result["result"]["no_op"] is True + assert calls == [ + ( + "GET", + f"/repository-renames/operations/{OPERATION_ID}", + None, + ) + ] + + +def test_apply_uses_files_and_names_the_next_safe_phase( + monkeypatch, tmp_path, capsys +): + evidence = tmp_path / "evidence.json" + evidence.write_text('{"fresh_clone": true}') + evidence.chmod(0o600) + calls = [] + + def request(_api_base, method, path, body=None): + calls.append((method, path, body)) + if method == "GET": + return _operation("statehub-rebound") + return _operation("source-synced") + + monkeypatch.setattr(rename_cli, "_api_request", request) + _run( + monkeypatch, + "repo", "rename", "apply", OPERATION_ID, + "--phase", "source-synced", + "--confirm", CONFIRMATION, + "--evidence-file", str(evidence), + "--json", + ) + + result = json.loads(capsys.readouterr().out) + assert result["state"] == "planned" + assert result["phase"] == "source-synced" + assert result["next_safe_action"]["phase"] == "consumers-verified" + apply_body = calls[1][2] + assert apply_body["expected_phase"] == "statehub-rebound" + assert apply_body["evidence"] == {"fresh_clone": True} + + +def test_rollback_requires_separate_execute_flag(monkeypatch, capsys): + calls = [] + + def request(_api_base, method, path, body=None): + calls.append((method, path, body)) + if method == "GET": + return _operation("source-synced") + return { + "safe_to_rollback": True, + "operation": _operation("rollback-preflight"), + } + + monkeypatch.setattr(rename_cli, "_api_request", request) + _run( + monkeypatch, + "repo", "rename", "rollback", OPERATION_ID, + "--confirm", f"rollback:{OPERATION_ID}", + "--json", + ) + result = json.loads(capsys.readouterr().out) + assert result["phase"] == "rollback-preflight" + assert result["next_safe_action"]["action"] == "execute-rollback" + assert [method for method, _path, _body in calls] == ["GET", "POST"] + + +def test_rollback_execute_reaches_terminal_state(monkeypatch, capsys): + calls = [] + + def request(_api_base, method, path, body=None): + calls.append((method, path, body)) + return ( + _operation("rollback-preflight") + if method == "GET" + else _operation("rolled-back") + ) + + monkeypatch.setattr(rename_cli, "_api_request", request) + _run( + monkeypatch, + "repo", "rename", "rollback", OPERATION_ID, + "--confirm", f"rollback:{OPERATION_ID}", + "--execute", + "--json", + ) + result = json.loads(capsys.readouterr().out) + assert result["state"] == "rolled-back" + assert result["next_safe_action"]["action"] == "none" + assert [method for method, _path, _body in calls] == ["GET", "POST"] + + +def test_json_error_output_redacts_credentials_and_userinfo(monkeypatch, capsys): + def request(*_args, **_kwargs): + raise rename_cli.RenameCLIError( + "failed via https://user:password@forge.example/repo" + "?X-Amz-Credential=url-credential&X-Amz-Signature=url-secret", + code="simulated", + details={"authorization": "Bearer top-secret", "password": "hidden"}, + ) + + monkeypatch.setattr(rename_cli, "_api_request", request) + with pytest.raises(SystemExit) as exc: + _run( + monkeypatch, + "repo", "rename", "status", OPERATION_ID, "--json", + ) + assert exc.value.code == 1 + output = capsys.readouterr().out + assert "user:password" not in output + assert "top-secret" not in output + assert "hidden" not in output + assert "url-secret" not in output + assert "url-credential" not in output + assert "[REDACTED]" in output + + +def test_invalid_confirmation_stops_before_mutating_request(monkeypatch, capsys): + calls = [] + + def request(_api_base, method, path, body=None): + calls.append((method, path, body)) + return _operation("preflighted") + + monkeypatch.setattr(rename_cli, "_api_request", request) + with pytest.raises(SystemExit) as exc: + _run( + monkeypatch, + "repo", "rename", "apply", OPERATION_ID, + "--phase", "forge-renamed", + "--confirm", "yes", + "--json", + ) + assert exc.value.code == 1 + assert [method for method, _path, _body in calls] == ["GET"] + result = json.loads(capsys.readouterr().out) + assert result["error"]["code"] == "confirmation_mismatch" + + +def test_unsafe_preflight_is_machine_detectable(monkeypatch, capsys): + def request(_api_base, method, _path, body=None): + del body + if method == "GET": + return _repo() + return { + **_preflight(), + "safe_to_apply": False, + "blockers": [{"code": "target_exists"}], + "preflight_token": None, + } + + monkeypatch.setattr(rename_cli, "_api_request", request) + with pytest.raises(SystemExit) as exc: + _run( + monkeypatch, + "repo", + "rename", + "preflight", + "flex-auth", + "access-engine", + "--json", + ) + assert exc.value.code == 2 + result = json.loads(capsys.readouterr().out) + assert result["state"] == "failed" + assert result["next_safe_action"]["action"] == "resolve-blockers" + + +def test_invalid_operation_id_is_stable_json(monkeypatch, capsys): + monkeypatch.setattr( + rename_cli, + "_api_request", + lambda *_args, **_kwargs: pytest.fail("API should not be called"), + ) + with pytest.raises(SystemExit) as exc: + _run( + monkeypatch, + "repo", + "rename", + "status", + "not-a-uuid", + "--json", + ) + assert exc.value.code == 1 + result = json.loads(capsys.readouterr().out) + assert result["schema_version"] == rename_cli.CLI_SCHEMA_VERSION + assert result["state"] == "failed" + assert result["error"]["code"] == "invalid_operation_id" + + +@pytest.mark.parametrize( + ("operation", "label"), + [ + (_operation("preflighted"), "PLANNED"), + (_operation("completed"), "ACHIEVED"), + ({**_operation("source-synced"), "error_code": "failed"}, "FAILED"), + (_operation("rolled-back"), "ROLLED-BACK"), + ], +) +def test_human_status_distinguishes_lifecycle_states( + monkeypatch, capsys, operation, label +): + monkeypatch.setattr( + rename_cli, + "_api_request", + lambda *_args, **_kwargs: operation, + ) + if label == "FAILED": + with pytest.raises(SystemExit) as exc: + _run(monkeypatch, "repo", "rename", "status", OPERATION_ID) + assert exc.value.code == 2 + else: + _run(monkeypatch, "repo", "rename", "status", OPERATION_ID) + captured = capsys.readouterr() + assert f"{label}: repository rename status" in captured.out + captured.err diff --git a/workplans/STATE-WP-0085-repository-lineage-preserving-rename.md b/workplans/STATE-WP-0085-repository-lineage-preserving-rename.md index 06de423..b614a55 100644 --- a/workplans/STATE-WP-0085-repository-lineage-preserving-rename.md +++ b/workplans/STATE-WP-0085-repository-lineage-preserving-rename.md @@ -346,7 +346,7 @@ pass (the build retains one pre-existing `/docs/intakes` broken-link warning). ```task id: STATE-WP-0085-T05 -status: todo +status: done priority: high state_hub_task_id: "d85da2ea-8037-5117-a41a-d400ca68bf10" ``` @@ -376,6 +376,26 @@ Acceptance: - CLI retries preserve operation identity; - no command logs credentials, authorization headers, or secret-bearing URLs. +Result (2026-08-29): added `statehub repo rename` +`preflight`/`start`/`apply`/`status`/`verify`/`rollback` commands over the +repository-ID-addressed lifecycle. The versioned +`state-hub.repository-rename-cli.v1` JSON envelope reports lifecycle state and +the next safe command; human output distinguishes planned, achieved, failed, +and rolled-back operations. Preflight tokens are never printed and are passed +to `start` through an exclusive mode-0600 JSON file (or stdin). Evidence and +check inputs receive the same private-file guard, credential-bearing response +fields and URLs are redacted, and no Forgejo credential option exists. +Mutations require a client-owned operation UUID plus the exact rename or +rollback confirmation. State Hub accepts and globally resolves that UUID; +same-intent creation retries remain no-ops after token expiry or a canonical +slug rebind, while changed target/actor intent fails closed. Rollback preflight +and execution are deliberately separate. Wheel force-includes make the +declared `statehub` entry point installable outside the checkout. Verification: +789 repository tests and the 70-page dashboard build passed before the final +retry hardening; the resulting focused suites pass 14 CLI and 7 API tests, and +a clean wheel installation exposes the complete rename command tree. The +dashboard retains the pre-existing `/docs/intakes` broken-link warning. + ## Generate a target-repository migration workplan ```task