feat(devkit): add resumable workspace automation and UI review tooling
Verified with the coordinated workspace changes by devkit full run 2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed). This shared UI pass does not mark the individual module reviews complete.
This commit is contained in:
Executable
+366
@@ -0,0 +1,366 @@
|
||||
"""Headless adapter to the existing, receipt-bound release console lifecycle.
|
||||
|
||||
The ASGI application runs in this process; no HTTP socket or background server
|
||||
is started. Critical release orchestration remains in the existing service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from .common import redact
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[3]
|
||||
RELEASE_ROOT = META_ROOT / "tools/release"
|
||||
_REPOSITORY = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z")
|
||||
_VERSION = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z][0-9A-Za-z.-]*)?\Z")
|
||||
_RUN = re.compile(r"rr-(?:[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}|request-[0-9a-f]{64})\Z")
|
||||
_STEP = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:@-]{0,159}\Z")
|
||||
_REQUEST = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:@/-]{7,127}\Z")
|
||||
|
||||
|
||||
def _typed(pattern: re.Pattern[str], label: str):
|
||||
def parse(value: str) -> str:
|
||||
if not pattern.fullmatch(value):
|
||||
raise argparse.ArgumentTypeError(f"Invalid {label}.")
|
||||
return value
|
||||
return parse
|
||||
|
||||
|
||||
def _limit(value: str) -> int:
|
||||
try:
|
||||
result = int(value)
|
||||
except ValueError as exc:
|
||||
raise argparse.ArgumentTypeError("Limit must be an integer from 1 to 100.") from exc
|
||||
if not 1 <= result <= 100:
|
||||
raise argparse.ArgumentTypeError("Limit must be an integer from 1 to 100.")
|
||||
return result
|
||||
|
||||
|
||||
def _planning_options(parser: argparse.ArgumentParser, *, selection: bool) -> None:
|
||||
if selection:
|
||||
parser.add_argument("--repo", action="append", default=[], type=_typed(_REPOSITORY, "repository name"))
|
||||
parser.add_argument("--repo-version", action="append", default=[], metavar="REPO=VERSION")
|
||||
parser.add_argument("--target-version", type=_typed(_VERSION, "target version"))
|
||||
parser.add_argument("--channel", default="stable")
|
||||
parser.add_argument("--online", action="store_true", help="Allow the existing remote/catalog checks.")
|
||||
parser.add_argument("--remote-tags", action="store_true", help="Explicitly inspect remote Git tags.")
|
||||
parser.add_argument("--public-catalog", action="store_true", help="Explicitly inspect the public catalog.")
|
||||
parser.add_argument("--include-migrations", action="store_true", help="Run migration audits; never applies migrations.")
|
||||
|
||||
|
||||
def register(subparsers: Any) -> None:
|
||||
"""Register release commands without importing FastAPI, HTTPX or Core."""
|
||||
parser = subparsers.add_parser("release", help="Plan and operate durable GovOPlaN release runs.")
|
||||
commands = parser.add_subparsers(dest="release_command", required=True)
|
||||
for name in ("plan", "status", "create"):
|
||||
command = commands.add_parser(name)
|
||||
_planning_options(command, selection=name != "status")
|
||||
if name == "status":
|
||||
command.add_argument("--include-website", action="store_true")
|
||||
if name == "create":
|
||||
command.add_argument("--request-id", required=True, type=_typed(_REQUEST, "request ID"))
|
||||
command.add_argument("--apply", action="store_true", help="Persist the frozen run; otherwise preview only.")
|
||||
command.set_defaults(handler=handle)
|
||||
|
||||
listing = commands.add_parser("list", help="List bounded, workspace-scoped durable run history.")
|
||||
listing.add_argument("--limit", type=_limit, default=20)
|
||||
listing.add_argument("--cursor")
|
||||
listing.set_defaults(handler=handle)
|
||||
for name in ("show", "preview", "execute", "resume", "retry", "reconcile"):
|
||||
command = commands.add_parser(name)
|
||||
command.add_argument("run_id", type=_typed(_RUN, "run ID"))
|
||||
if name in {"preview", "execute", "retry", "reconcile"}:
|
||||
command.add_argument("step_id", type=_typed(_STEP, "step ID"))
|
||||
if name in {"execute", "resume", "retry", "reconcile"}:
|
||||
command.add_argument("--request-id", required=True, type=_typed(_REQUEST, "request ID"))
|
||||
command.add_argument("--apply", action="store_true", help="Apply this explicit durable transition; otherwise preview only.")
|
||||
if name in {"execute", "reconcile"}:
|
||||
command.add_argument("--confirm", default="", help="Exact confirmation required by the existing release service.")
|
||||
if name == "execute":
|
||||
command.add_argument("--signing-key", action="append", default=[], metavar="KEY_ID=PRIVATE_KEY_FILE")
|
||||
if name == "reconcile":
|
||||
command.add_argument("--outcome", required=True, choices=("effect_absent", "effect_succeeded", "unresolved"))
|
||||
command.set_defaults(handler=handle)
|
||||
|
||||
|
||||
def _selection(args: argparse.Namespace) -> tuple[list[str], dict[str, str]]:
|
||||
versions: dict[str, str] = {}
|
||||
for item in args.repo_version:
|
||||
repo, separator, version = item.partition("=")
|
||||
if not separator or not _REPOSITORY.fullmatch(repo) or not _VERSION.fullmatch(version):
|
||||
raise ValueError("--repo-version must be REPO=VERSION with a valid repository and version.")
|
||||
if repo in versions and versions[repo] != version:
|
||||
raise ValueError(f"Conflicting target versions for {repo}.")
|
||||
versions[repo] = version
|
||||
repos = list(dict.fromkeys([*args.repo, *versions]))
|
||||
if not repos:
|
||||
raise ValueError("Select at least one --repo or --repo-version explicitly.")
|
||||
for repo in repos:
|
||||
if repo not in versions and args.target_version:
|
||||
versions[repo] = args.target_version
|
||||
if args.release_command == "create" and any(repo not in versions for repo in repos):
|
||||
raise ValueError("Creating a run requires an explicit version for every selected repository.")
|
||||
return repos, versions
|
||||
|
||||
|
||||
def _planning_query(args: argparse.Namespace) -> dict[str, Any]:
|
||||
return {
|
||||
"channel": args.channel,
|
||||
"online": args.online,
|
||||
"remote_tags": args.remote_tags,
|
||||
"public_catalog": args.public_catalog or args.online,
|
||||
"include_migrations": args.include_migrations,
|
||||
**({"target_version": args.target_version} if args.target_version else {}),
|
||||
}
|
||||
|
||||
|
||||
def _load_application(args: argparse.Namespace) -> tuple[Any, str]:
|
||||
# The generic CLI/help path stays dependency-light. The backend still checks
|
||||
# its operator-controlled runtime and registered source origins on apply.
|
||||
for name, loaded in list(sys.modules.items()):
|
||||
if name in {"server", "govoplan_release"} or name.startswith(("server.", "govoplan_release.")):
|
||||
expected = RELEASE_ROOT / ("server" if name.startswith("server") else "govoplan_release")
|
||||
source = getattr(loaded, "__file__", None)
|
||||
if not isinstance(source, str) or not Path(source).resolve().is_relative_to(expected.resolve()):
|
||||
raise ValueError("A foreign module shadows the trusted GovOPlaN release service.")
|
||||
# Reprioritize even when the path was added previously below an unrelated
|
||||
# working directory. Validate cached packages before importing any submodule.
|
||||
sys.path[:] = [str(RELEASE_ROOT), *(path for path in sys.path if path != str(RELEASE_ROOT))]
|
||||
module = importlib.import_module("server.app")
|
||||
if Path(module.__file__).resolve() != (RELEASE_ROOT / "server/app.py").resolve():
|
||||
raise ValueError("A foreign server.app module shadows the GovOPlaN release service.")
|
||||
token = secrets.token_urlsafe(32)
|
||||
state_dir = getattr(args, "state_dir", None)
|
||||
app = module.create_app(
|
||||
workspace_root=Path(args.workspace_root).expanduser().resolve(),
|
||||
token=token,
|
||||
run_state_root=Path(state_dir) / "release-console" if state_dir is not None else None,
|
||||
)
|
||||
return app, token
|
||||
|
||||
|
||||
def _error_detail(payload: Any) -> str:
|
||||
detail = payload.get("detail") if isinstance(payload, dict) else None
|
||||
if isinstance(detail, str):
|
||||
return detail
|
||||
if isinstance(detail, list):
|
||||
# Validation input may contain signing-key arguments. Never echo it.
|
||||
return "; ".join(
|
||||
".".join(str(part) for part in item.get("loc", [])) + ": " + str(item.get("msg", "Invalid request"))
|
||||
for item in detail if isinstance(item, dict)
|
||||
)
|
||||
return "The release service did not return a valid successful response."
|
||||
|
||||
|
||||
class _ServiceError(Exception):
|
||||
def __init__(self, status: int, payload: Any):
|
||||
super().__init__(_error_detail(payload))
|
||||
self.status = status
|
||||
|
||||
|
||||
def _brief(value: Any, *, limit: int = 240) -> str:
|
||||
"""Bound display-only fields; never serialize an executor or its arguments."""
|
||||
if not isinstance(value, (str, int, float, bool)):
|
||||
return "unknown"
|
||||
text = " ".join(redact(str(value)).split())
|
||||
return text if len(text) <= limit else text[:limit - 1] + "…"
|
||||
|
||||
|
||||
def _status(payload: dict[str, Any]) -> str:
|
||||
status = payload.get("status")
|
||||
for key in ("summary", "state"):
|
||||
if isinstance(payload.get(key), dict):
|
||||
status = payload[key].get("status", status)
|
||||
if isinstance(payload.get("state_step"), dict):
|
||||
status = payload["state_step"].get("state", status)
|
||||
# Return the semantic value unchanged: display redaction must never affect
|
||||
# failure exit codes, even when an environment secret happens to equal it.
|
||||
return status if isinstance(status, str) else "ok"
|
||||
|
||||
|
||||
def _summary_lines(name: str, payload: dict[str, Any], note: str | None = None) -> list[str]:
|
||||
"""Compact, bounded projection; the unchanged JSON result retains details."""
|
||||
lines = [f"Release {name}: {_brief(_status(payload))}."]
|
||||
if note:
|
||||
lines.append(note)
|
||||
plan = payload.get("immutable", {}).get("plan", {}) if isinstance(payload.get("immutable"), dict) else payload
|
||||
if not isinstance(plan, dict):
|
||||
plan = {}
|
||||
if isinstance(plan.get("source_preflight_ready"), bool):
|
||||
prefix = "Frozen plan source" if "immutable" in payload else "Source"
|
||||
lines.append(f"{prefix} preflight ready: {str(plan['source_preflight_ready']).lower()}.")
|
||||
units = plan.get("units", [])
|
||||
if isinstance(units, list):
|
||||
for unit in units[:12]:
|
||||
if isinstance(unit, dict):
|
||||
lines.append(f"{_brief(unit.get('repo'))}: {_brief(unit.get('status', 'planned'))}; target {_brief(unit.get('target_version'))}.")
|
||||
if len(units) > 12:
|
||||
lines.append(f"{len(units) - 12} more selected repositories; use --json for every repository.")
|
||||
dashboard = payload.get("summary")
|
||||
if isinstance(dashboard, dict):
|
||||
counts = [f"{dashboard[key]} {label}" for key, label in (
|
||||
("repository_count", "repositories"), ("missing_count", "missing"),
|
||||
("dirty_count", "dirty"), ("ahead_count", "ahead"),
|
||||
("behind_count", "behind"), ("error_count", "errors"),
|
||||
) if isinstance(dashboard.get(key), int)]
|
||||
if counts:
|
||||
lines.append("Repository status: " + ", ".join(counts) + ".")
|
||||
findings = plan.get("gate_findings", payload.get("collection_errors", []))
|
||||
if isinstance(findings, list):
|
||||
for finding in findings[:4]:
|
||||
if isinstance(finding, dict):
|
||||
scope = f" ({_brief(finding['repo'])})" if finding.get("repo") else ""
|
||||
lines.append(f"Gate {_brief(finding.get('code'))}{scope}: {_brief(finding.get('message'))}")
|
||||
if len(findings) > 4:
|
||||
lines.append(f"{len(findings) - 4} more gate findings; use --json for details.")
|
||||
state = payload.get("state", {})
|
||||
steps = state.get("steps", []) if isinstance(state, dict) else []
|
||||
if isinstance(payload.get("state_step"), dict):
|
||||
steps = [payload["state_step"]]
|
||||
if isinstance(steps, list) and steps:
|
||||
counts: dict[str, int] = {}
|
||||
for step in steps:
|
||||
if isinstance(step, dict):
|
||||
status = _brief(step.get("state", "unknown"))
|
||||
counts[status] = counts.get(status, 0) + 1
|
||||
lines.append("Steps: " + ", ".join(f"{count} {state}" for state, count in sorted(counts.items())) + ".")
|
||||
relevant = [step for step in steps if isinstance(step, dict) and step.get("state") != "succeeded"]
|
||||
for step in relevant[:3]:
|
||||
lines.append(f"Step {_brief(step.get('id'))}: {_brief(step.get('state'))}." +
|
||||
(f" {_brief(step['disabled_reason'])}" if step.get("disabled_reason") else ""))
|
||||
execution = payload.get("execution_result")
|
||||
if isinstance(execution, dict):
|
||||
lines.append(f"Executor result: {_brief(execution.get('status', 'recorded'))}.")
|
||||
recommendation = payload.get("recommended_next", plan.get("recommended_action"))
|
||||
if isinstance(recommendation, dict) and recommendation:
|
||||
step = f" [{_brief(recommendation['step_id'])}]" if recommendation.get("step_id") else ""
|
||||
lines.append(f"Next: {_brief(recommendation.get('id'))}{step} — {_brief(recommendation.get('title'))}.")
|
||||
if recommendation.get("remediation"):
|
||||
lines.append(_brief(recommendation["remediation"]))
|
||||
runs = payload.get("runs")
|
||||
if isinstance(runs, list):
|
||||
lines.append(f"{len(runs)} release runs in this page.")
|
||||
for run in runs[:12]:
|
||||
if isinstance(run, dict):
|
||||
lines.append(f"{_brief(run.get('run_id'))}: {_brief(_status(run))}.")
|
||||
if payload.get("next_cursor"):
|
||||
lines.append("More history available; use --json for the next cursor.")
|
||||
return lines
|
||||
|
||||
|
||||
async def _run(args: argparse.Namespace) -> dict[str, Any]:
|
||||
# Resolve CLI-only validation before loading the service or creating state.
|
||||
if getattr(args, "project", None) is not None:
|
||||
raise ValueError("Release uses the authoritative GovOPlaN catalog and does not accept --project overrides; select the registered --workspace-root instead.")
|
||||
name = args.release_command
|
||||
selection = _selection(args) if name in {"plan", "create"} else None
|
||||
keys = getattr(args, "signing_key", [])
|
||||
if len(keys) > 8 or any(not re.fullmatch(r"[A-Za-z0-9._-]{1,128}=.+", key) or "-----BEGIN" in key or "\n" in key or "\r" in key or len(key) > 4096 for key in keys):
|
||||
raise ValueError("Provide at most eight --signing-key KEY_ID=PRIVATE_KEY_FILE arguments, never key material.")
|
||||
import httpx
|
||||
app, token = _load_application(args)
|
||||
metadata = {
|
||||
"workspace_root": str(app.state.workspace_root),
|
||||
"state_location": str(app.state.release_runs.root),
|
||||
"candidate_location": str(app.state.release_candidate_root),
|
||||
}
|
||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="http://govoplan-devkit.invalid",
|
||||
headers={"X-Release-Console-Token": token}, timeout=None,
|
||||
follow_redirects=False,
|
||||
) as client:
|
||||
async def request(method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
||||
response = await client.request(method, path, **kwargs)
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
payload = None
|
||||
if response.status_code >= 400 or not isinstance(payload, dict):
|
||||
raise _ServiceError(response.status_code, payload)
|
||||
return payload
|
||||
|
||||
def result(payload: dict[str, Any], *, dry_run: bool = False, summary: str | None = None) -> dict[str, Any]:
|
||||
status = _status(payload)
|
||||
execution_failed = isinstance(payload.get("execution_result"), dict) and payload["execution_result"].get("status") == "failed"
|
||||
return {
|
||||
**metadata, "operation": name, "dry_run": dry_run,
|
||||
"result": payload,
|
||||
"_exit_code": 1 if execution_failed or status in {"blocked", "failed", "interrupted"} else 0,
|
||||
"summary": [*_summary_lines(name, payload, summary), f"Durable state: {metadata['state_location']}"],
|
||||
}
|
||||
|
||||
async def preview(run: dict[str, Any], step_id: str) -> dict[str, Any]:
|
||||
plan = run.get("immutable", {}).get("plan", {})
|
||||
plan_step = next((step for step in plan.get("dry_run_steps", []) if step.get("id") == step_id), None)
|
||||
state_step = next((step for step in run.get("state", {}).get("steps", []) if step.get("id") == step_id), None)
|
||||
if plan_step is None or state_step is None:
|
||||
raise _ServiceError(404, {"detail": "Release run step was not found."})
|
||||
if state_step.get("executor", {}).get("kind") == "catalog_publish":
|
||||
return await request("POST", f"{run_path}/steps/{quote(step_id, safe='')}/preview", json={"remote": "origin"})
|
||||
return {
|
||||
"run_id": run["run_id"], "plan_step": plan_step, "state_step": state_step,
|
||||
"note": "Frozen-plan inspection only; no executor was called and no live preflight is claimed.",
|
||||
}
|
||||
|
||||
if name == "status":
|
||||
payload = await request("GET", "/api/dashboard", params={**_planning_query(args), "include_website": args.include_website})
|
||||
return result(payload)
|
||||
if name == "list":
|
||||
params = {"limit": args.limit, **({"cursor": args.cursor} if args.cursor else {})}
|
||||
return result(await request("GET", "/api/release-runs", params=params))
|
||||
if name in {"plan", "create"}:
|
||||
assert selection is not None
|
||||
repos, versions = selection
|
||||
query = {
|
||||
**_planning_query(args), "repos": ",".join(repos),
|
||||
"repo_versions": ",".join(f"{repo}={version}" for repo, version in versions.items()),
|
||||
}
|
||||
if name == "plan" or not args.apply:
|
||||
payload = await request("GET", "/api/selective-plan", params=query)
|
||||
return result(payload, dry_run=True, summary="Release plan inspected; no run was created and no release step executed.")
|
||||
body = {key: value for key, value in _planning_query(args).items() if key != "target_version"}
|
||||
payload = await request("POST", "/api/release-runs", json={**body, "request_id": args.request_id, "repo_versions": versions})
|
||||
return result(payload)
|
||||
|
||||
run_path = "/api/release-runs/" + quote(args.run_id, safe="")
|
||||
if name == "show":
|
||||
return result(await request("GET", run_path))
|
||||
if name == "preview" or not args.apply:
|
||||
run = await request("GET", run_path)
|
||||
payload = await preview(run, args.step_id) if name in {"preview", "execute"} else run
|
||||
return result(payload, dry_run=True, summary=f"Release {name} inspected; no durable transition or executor was invoked.")
|
||||
body = {"request_id": args.request_id}
|
||||
if name in {"execute", "reconcile"}:
|
||||
body["confirm"] = args.confirm
|
||||
if name == "execute":
|
||||
body.update({"remote": "origin", "signing_keys": keys})
|
||||
if name == "reconcile":
|
||||
body["outcome"] = args.outcome
|
||||
path = f"{run_path}/resume" if name == "resume" else f"{run_path}/steps/{quote(args.step_id, safe='')}/{name}"
|
||||
return result(await request("POST", path, json=body))
|
||||
|
||||
|
||||
def handle(args: argparse.Namespace) -> dict[str, Any]:
|
||||
"""Return the common devkit JSON/summary envelope; never retry mutations."""
|
||||
try:
|
||||
return asyncio.run(_run(args))
|
||||
except _ServiceError as exc:
|
||||
summary = [f"Release {args.release_command} failed (HTTP {exc.status}): {exc}"]
|
||||
if args.release_command in {"create", "execute", "resume", "retry", "reconcile"}:
|
||||
summary.append("No automatic retry occurred. Inspect the run; reuse the same request ID for a known replay, or resume/reconcile an uncertain effect before a new attempt.")
|
||||
return {"_exit_code": 1, "status": "error", "http_status": exc.status, "summary": summary}
|
||||
except ModuleNotFoundError as exc:
|
||||
return {"_exit_code": 2, "status": "unavailable", "summary": [f"Release commands need the GovOPlaN development dependencies ({exc.name} is unavailable)."]}
|
||||
except ValueError as exc:
|
||||
return {"_exit_code": 2, "status": "invalid", "summary": [str(exc)]}
|
||||
Reference in New Issue
Block a user