"""State Hub dual-run adapter → Repo Manager (RMGR-WP-0002/0003 Stage B). Config file (same as repo-manager): ~/.repo-manager/dual-run.yaml or RM_DUAL_RUN_CONFIG. Env overrides when set: RM_WRITEBACK, RM_RECONCILE, RM_WRITEBACK_PUSH, RM_PILOT_REPOS, RM_METER_PATH Rollback: disable in config / unset env → native State Hub path. """ from __future__ import annotations import json import logging import os import subprocess import sys import uuid from functools import lru_cache from pathlib import Path from typing import Any import yaml logger = logging.getLogger(__name__) _TRUE = frozenset({"1", "true", "yes", "on"}) _FALSE = frozenset({"0", "false", "no", "off"}) def config_path() -> Path: raw = os.environ.get("RM_DUAL_RUN_CONFIG", "~/.repo-manager/dual-run.yaml") return Path(raw).expanduser() @lru_cache(maxsize=1) def _load_file_config() -> dict[str, Any]: path = config_path() if not path.is_file(): return {} try: data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} except (OSError, yaml.YAMLError): return {} return data if isinstance(data, dict) else {} def reload_config() -> None: _load_file_config.cache_clear() def _env_bool(name: str) -> bool | None: raw = os.environ.get(name) if raw is None or raw.strip() == "": return None v = raw.strip().lower() if v in _TRUE: return True if v in _FALSE: return False return None def _cfg_bool(key: str, env_name: str, default: bool = False) -> bool: ev = _env_bool(env_name) if ev is not None: return ev cfg = _load_file_config() if key in cfg: val = cfg[key] if isinstance(val, bool): return val if isinstance(val, str): return val.strip().lower() in _TRUE return default def writeback_enabled() -> bool: return _cfg_bool("writeback", "RM_WRITEBACK", False) def reconcile_enabled() -> bool: return _cfg_bool("reconcile", "RM_RECONCILE", False) def writeback_push_enabled() -> bool: return _cfg_bool("writeback_push", "RM_WRITEBACK_PUSH", False) def pilot_slugs() -> set[str] | None: raw = os.environ.get("RM_PILOT_REPOS") if raw is not None: raw = raw.strip() if not raw: return set() return {s.strip() for s in raw.split(",") if s.strip()} cfg = _load_file_config() if "pilot_repos" not in cfg: return None val = cfg["pilot_repos"] if val is None: return None if isinstance(val, str): if not val.strip(): return set() return {s.strip() for s in val.split(",") if s.strip()} if isinstance(val, list): return {str(s).strip() for s in val if str(s).strip()} return None def slug_allowed(slug: str | None) -> bool: if not slug: return False pilots = pilot_slugs() if pilots is None: return True return slug in pilots def writeback_for_repo(slug: str | None) -> bool: return writeback_enabled() and slug_allowed(slug) def reconcile_for_repo(slug: str | None) -> bool: return reconcile_enabled() and slug_allowed(slug) def meter_path() -> Path: raw = os.environ.get("RM_METER_PATH") if raw: return Path(raw).expanduser() cfg = _load_file_config() if cfg.get("meter_path"): return Path(str(cfg["meter_path"])).expanduser() return Path("~/.repo-manager/mutation-meter.jsonl").expanduser() def record_mutation( *, source: str, kind: str, repo_slug: str | None, detail: dict[str, Any] | None = None, ) -> None: try: from datetime import UTC, datetime path = meter_path() path.parent.mkdir(parents=True, exist_ok=True) row = { "ts": datetime.now(UTC).isoformat().replace("+00:00", "Z"), "source": source, "kind": kind, "repo_slug": repo_slug, "detail": detail or {}, } with path.open("a", encoding="utf-8") as fh: fh.write(json.dumps(row, default=str) + "\n") except OSError: pass def run_rmgr(args: list[str], *, timeout: int = 120) -> tuple[int, str, str]: env = os.environ.copy() custom = os.environ.get("RMGR_BIN", "").strip() if custom: cmd = custom.split() + args else: src = os.environ.get("REPO_MANAGER_SRC", "").strip() sibling = Path(src) if src else (Path.home() / "repo-manager" / "src") if not sibling.is_dir() and (Path.home() / "repo-manager" / "src").is_dir(): sibling = Path.home() / "repo-manager" / "src" if sibling.is_dir(): env["PYTHONPATH"] = str(sibling) + ( os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "" ) cmd = [ sys.executable, "-c", "from repo_manager.cli import main; import sys; raise SystemExit(main(sys.argv[1:]))", *args, ] else: cmd = ["rmgr", *args] proc = subprocess.run( cmd, capture_output=True, text=True, timeout=timeout, check=False, env=env, ) return proc.returncode, proc.stdout, proc.stderr def rm_update_task_status( *, repo_path: str | Path, task_id: str, status: str, repo_slug: str | None = None, reason: str = "state-hub dual-run", push: bool | None = None, correlation_id: str | None = None, ) -> dict[str, Any]: correlation_id = correlation_id or str(uuid.uuid4()) if push is None: push = writeback_push_enabled() args = [ "update-task-status", "--path", str(repo_path), "--task-id", str(task_id), "--status", status, "--reason", reason, "--correlation-id", correlation_id, "--idempotency-key", f"sh-dual-{task_id}-{status}-{correlation_id}", ] if repo_slug: args.extend(["--slug", repo_slug]) if push: args.append("--push") code, out, err = run_rmgr(args) try: result = json.loads(out.strip() or "{}") except json.JSONDecodeError: result = { "status": "failed", "error": { "code": "internal", "message": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}", }, "correlation_id": correlation_id, } if code != 0 and result.get("status") not in ("applied", "rejected"): result.setdefault("status", "failed") result.setdefault( "error", {"code": "internal", "message": f"rmgr exit={code} stderr={err!r}"}, ) return result def rm_update_workplan( *, repo_path: str | Path, workplan_id: str, operation: str = "update", title: str | None = None, goal: str | None = None, status: str | None = None, owner: str | None = None, domain: str | None = None, topic_slug: str | None = None, repo_slug: str | None = None, reason: str = "state-hub dual-run", push: bool | None = None, correlation_id: str | None = None, confirm_archive: bool = False, ) -> dict[str, Any]: """Delegate a file-backed workplan mutation to Repo Manager. ``operation=archive`` is the recoverable counterpart of State Hub's DELETE workplan route; it never erases repository history. """ correlation_id = correlation_id or str(uuid.uuid4()) if operation not in {"create", "update", "archive"}: return { "status": "rejected", "correlation_id": correlation_id, "error": {"code": "validation_error", "message": f"invalid operation {operation!r}"}, } if push is None: push = writeback_push_enabled() cli_operation = "delete" if operation == "archive" else operation args = [ "workplan", cli_operation, "--path", str(repo_path), "--workplan-id", str(workplan_id), "--reason", reason, "--correlation-id", correlation_id, "--idempotency-key", f"sh-dual-workplan-{operation}-{workplan_id}-{correlation_id}", ] for flag, value in ( ("--title", title), ("--goal", goal), ("--status", status), ("--owner", owner), ("--domain", domain), ("--topic-slug", topic_slug), ("--slug", repo_slug), ): if value is not None: args.extend([flag, value]) if operation == "archive" and confirm_archive: args.append("--confirm") if push: args.append("--push") code, out, err = run_rmgr(args) try: result = json.loads(out.strip() or "{}") except json.JSONDecodeError: result = { "status": "failed", "error": { "code": "internal", "message": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}", }, "correlation_id": correlation_id, } if code != 0 and result.get("status") not in ("applied", "rejected"): result.setdefault("status", "failed") result.setdefault( "error", {"code": "internal", "message": f"rmgr exit={code} stderr={err!r}"}, ) return result def rm_update_register_entry( *, repo_path: str | Path, kind: str, entry_id: str, operation: str = "put", title: str | None = None, status: str | None = None, data: dict[str, Any] | None = None, note: str | None = None, author: str | None = None, repo_slug: str | None = None, reason: str = "state-hub retirement adapter", push: bool | None = None, correlation_id: str | None = None, ) -> dict[str, Any]: """Delegate register create/update/defer/note to Repo Manager.""" correlation_id = correlation_id or str(uuid.uuid4()) if operation not in {"put", "defer", "note"}: return { "status": "rejected", "correlation_id": correlation_id, "error": {"code": "validation_error", "message": f"invalid operation {operation!r}"}, } if push is None: push = writeback_push_enabled() args = [ "register", operation, "--path", str(repo_path), "--kind", kind, "--entry-id", entry_id, "--reason", reason, "--correlation-id", correlation_id, "--idempotency-key", f"sh-dual-register-{operation}-{kind}-{entry_id}-{correlation_id}", ] for flag, value in ( ("--title", title), ("--status", status), ("--note", note), ("--author", author), ("--slug", repo_slug), ): if value is not None: args.extend([flag, value]) if data is not None: args.extend(["--data-json", json.dumps(data, separators=(",", ":"))]) if push: args.append("--push") code, out, err = run_rmgr(args) try: result = json.loads(out.strip() or "{}") except json.JSONDecodeError: result = { "status": "failed", "error": { "code": "internal", "message": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}", }, "correlation_id": correlation_id, } if code != 0 and result.get("status") not in ("applied", "rejected"): result.setdefault("status", "failed") result.setdefault( "error", {"code": "internal", "message": f"rmgr exit={code} stderr={err!r}"}, ) return result def rm_scan_sbom( *, repo_path: str | Path, repo_slug: str | None = None, ) -> dict[str, Any]: """Derive a versioned SBOM snapshot from repository-owned sources.""" args = ["sbom", "scan", "--path", str(repo_path)] if repo_slug: args.extend(["--slug", repo_slug]) code, out, err = run_rmgr(args) try: result = json.loads(out.strip() or "{}") except json.JSONDecodeError: result = { "ok": False, "error": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}", } if code != 0: result.setdefault("ok", False) result.setdefault("error", f"rmgr exit={code} stderr={err!r}") result["exit_code"] = code return result def rm_scaffold( *, repo_path: str | Path, flavor: str, wp_prefix: str | None = None, slug: str | None = None, domain: str = "infotech", force: bool = False, commit: bool = False, ) -> dict[str, Any]: """Delegate repository scaffolding to ``rmgr scaffold`` (STATE-WP-0080-T02).""" args = [ "scaffold", "--path", str(repo_path), "--flavor", flavor, "--domain", domain, ] if slug: args.extend(["--slug", slug]) if wp_prefix: args.extend(["--wp-prefix", wp_prefix]) if force: args.append("--force") if not commit: args.append("--no-commit") code, out, err = run_rmgr(args) try: result = json.loads(out.strip() or "{}") except json.JSONDecodeError: result = { "status": "failed", "error": { "code": "internal", "message": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}", }, } if code != 0 and result.get("status") not in ("applied", "rejected"): result.setdefault("status", "failed") result.setdefault( "error", {"code": "internal", "message": f"rmgr exit={code} stderr={err!r}"}, ) result["exit_code"] = code return result def rm_reconcile(*, repo_path: str | Path, repo_slug: str | None = None) -> dict[str, Any]: args = ["reconcile", "--path", str(repo_path)] if repo_slug: args.extend(["--slug", repo_slug]) code, out, err = run_rmgr(args) try: result = json.loads(out.strip() or "{}") except json.JSONDecodeError: result = {"ok": False, "error": err or out, "exit_code": code} result["exit_code"] = code return result def try_writeback_for_task( *, repo_path: str | Path | None, repo_slug: str | None, task_id: str, status: str, reason: str = "state-hub dual-run PATCH /tasks", ) -> dict[str, Any] | None: if not writeback_for_repo(repo_slug): return None if not repo_path: logger.warning("RM dual-run writeback skipped: no repo_path for %s", repo_slug) return None path = Path(repo_path) if not path.is_dir(): logger.warning("RM dual-run writeback skipped: missing path %s", path) return None result = rm_update_task_status( repo_path=path, task_id=task_id, status=status, repo_slug=repo_slug, reason=reason, push=writeback_push_enabled(), ) if result.get("status") == "applied": record_mutation( source="repo-manager", kind="task_status_writeback_via_sh_facade", repo_slug=repo_slug, detail={ "task_id": task_id, "status": status, "git_sha": (result.get("evidence") or {}).get("git_sha"), "push_ok": (result.get("evidence") or {}).get("push_ok"), }, ) else: logger.warning( "RM dual-run writeback failed for %s task=%s: %s", repo_slug, task_id, result.get("error") or result, ) return result