feat: add repository rename orchestration CLI
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
This commit is contained in:
tegwick 2026-08-29 12:00:22 +02:00
parent 6312db8700
commit 6b82215ae7
11 changed files with 1373 additions and 4 deletions

View file

@ -325,7 +325,7 @@
| task | STATE-WP-0085-T02 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | | 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-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-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-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-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 | | task | STATE-WP-0085-T08 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |

View file

@ -111,6 +111,7 @@ app.include_router(recently_on_scope.router)
app.include_router(consistency_sweep.router) app.include_router(consistency_sweep.router)
app.include_router(repos.router) app.include_router(repos.router)
app.include_router(repository_renames.router) app.include_router(repository_renames.router)
app.include_router(repository_renames.operation_router)
app.include_router(topics.router) app.include_router(topics.router)
app.include_router(workstreams.router) app.include_router(workstreams.router)
app.include_router(workstreams.workplan_router) app.include_router(workstreams.workplan_router)

View file

@ -31,6 +31,7 @@ from api.services.repository_rename import (
create_operation, create_operation,
list_operations, list_operations,
load_operation, load_operation,
load_operation_by_id,
rollback_preflight, rollback_preflight,
verify_forge_identity, verify_forge_identity,
verify_operation, verify_operation,
@ -38,6 +39,9 @@ from api.services.repository_rename import (
router = APIRouter(prefix="/repos/{repo_id}", tags=["repository-renames"]) router = APIRouter(prefix="/repos/{repo_id}", tags=["repository-renames"])
operation_router = APIRouter(
prefix="/repository-renames", tags=["repository-renames"]
)
def _raise(exc: RenameLifecycleError) -> None: def _raise(exc: RenameLifecycleError) -> None:
@ -122,7 +126,22 @@ async def create_repository_rename_operation(
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway), gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
) -> dict[str, Any]: ) -> dict[str, Any]:
try: 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: except RenameLifecycleError as exc:
_raise(exc) _raise(exc)
return _operation_read(operation) return _operation_read(operation)

View file

@ -50,6 +50,7 @@ class ForgeIdentityVerifyRequest(BaseModel):
class RepositoryRenameOperationCreate(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-]*$") new_slug: str = Field(min_length=1, max_length=100, pattern=r"^[a-z0-9][a-z0-9-]*$")
preflight_token: str preflight_token: str
confirmation: str confirmation: str

View file

@ -695,7 +695,22 @@ async def create_operation(
gateway: ForgeRepositoryGateway, gateway: ForgeRepositoryGateway,
repo_id: uuid.UUID, repo_id: uuid.UUID,
body: RepositoryRenameOperationCreate, 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) token = _verify_preflight_token(body.preflight_token)
if token.get("repo_id") != str(repo_id) or token.get("new_slug") != body.new_slug: 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") raise RenamePreconditionFailed("Preflight token does not address this rename")
@ -730,6 +745,7 @@ async def create_operation(
forge = preflight["current"]["forge"] forge = preflight["current"]["forge"]
now = utcnow() now = utcnow()
operation = RepositoryRenameOperation( operation = RepositoryRenameOperation(
id=body.operation_id or uuid.uuid4(),
repo_id=repo.id, repo_id=repo.id,
forge_identity_id=identity.id, forge_identity_id=identity.id,
forge_identity_state="verified", forge_identity_state="verified",
@ -777,10 +793,33 @@ async def create_operation(
await session.commit() await session.commit()
except IntegrityError as exc: except IntegrityError as exc:
await session.rollback() 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( raise RenamePreconditionFailed(
"A conflicting repository rename operation was created" "A conflicting repository rename operation was created"
) from exc ) from exc
await session.refresh(operation) 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 return operation

View file

@ -22,6 +22,7 @@ import urllib.parse
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
from repository_rename_cli import configure_repo_commands
from statehub_register import run_register as run_statehub_register from statehub_register import run_register as run_statehub_register
STATE_HUB_DIR = Path(__file__).resolve().parent STATE_HUB_DIR = Path(__file__).resolve().parent
@ -728,6 +729,8 @@ def main() -> None:
) )
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="command", required=True)
configure_repo_commands(sub)
# register # register
statehub_reg = sub.add_parser( statehub_reg = sub.add_parser(
"register", "register",

View file

@ -33,6 +33,7 @@ build-backend = "hatchling.build"
packages = ["api", "mcp_server", "task_flow_engine"] packages = ["api", "mcp_server", "task_flow_engine"]
artifacts = [ artifacts = [
"custodian_cli.py", "custodian_cli.py",
"repository_rename_cli.py",
"statehub_register.py", "statehub_register.py",
"scripts/consistency_check.py", "scripts/consistency_check.py",
"scripts/repo_sync.py", "scripts/repo_sync.py",
@ -41,6 +42,17 @@ artifacts = [
"scripts/project_rules/*.template", "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] [tool.uv.sources]
llm-connect = { path = "/home/worsch/llm-connect", editable = true } llm-connect = { path = "/home/worsch/llm-connect", editable = true }
hub-core = { path = "/home/worsch/hub-core", editable = true } hub-core = { path = "/home/worsch/hub-core", editable = true }

835
repository_rename_cli.py Normal file
View file

@ -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", "<evidence.json>"])
if target == "consumers-verified":
command.extend(["--checks-file", "<checks.json>"])
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", "<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", "<preflight-report.json>",
],
}
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",
]

View file

@ -154,6 +154,55 @@ async def test_dry_run_has_no_persistent_changes(client, test_engine, rename_set
assert after == before 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 @pytest.mark.asyncio
async def test_interrupt_resume_every_phase_and_preserve_uuid(client, rename_setup): async def test_interrupt_resume_every_phase_and_preserve_uuid(client, rename_setup):
repo, forge = rename_setup repo, forge = rename_setup

View file

@ -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

View file

@ -346,7 +346,7 @@ pass (the build retains one pre-existing `/docs/intakes` broken-link warning).
```task ```task
id: STATE-WP-0085-T05 id: STATE-WP-0085-T05
status: todo status: done
priority: high priority: high
state_hub_task_id: "d85da2ea-8037-5117-a41a-d400ca68bf10" state_hub_task_id: "d85da2ea-8037-5117-a41a-d400ca68bf10"
``` ```
@ -376,6 +376,26 @@ Acceptance:
- CLI retries preserve operation identity; - CLI retries preserve operation identity;
- no command logs credentials, authorization headers, or secret-bearing URLs. - 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 ## Generate a target-repository migration workplan
```task ```task