From 32fc1719aa0a63a41c03deddc5583875e9623e93 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 8 Jul 2026 22:49:19 -0400 Subject: [PATCH 1/4] feat: allow reconciler profile to run merged cleanup directly (Closes #523) --- task_capability_map.py | 4 +- tests/test_audit_reconciliation_mode.py | 2 +- tests/test_reconciler_cleanup_integration.py | 112 +++++++++++++++++++ webui/lease_loader.py | 3 +- 4 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 tests/test_reconciler_cleanup_integration.py diff --git a/task_capability_map.py b/task_capability_map.py index c86ce2a..8d3daf2 100644 --- a/task_capability_map.py +++ b/task_capability_map.py @@ -106,11 +106,11 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = { }, "reconcile_merged_cleanups": { "permission": "gitea.read", - "role": "author", + "role": "reconciler", }, "reconciliation_cleanup": { "permission": "gitea.branch.delete", - "role": "author", + "role": "reconciler", }, "work_issue": { "permission": "gitea.pr.create", diff --git a/tests/test_audit_reconciliation_mode.py b/tests/test_audit_reconciliation_mode.py index a117fab..e429ddf 100644 --- a/tests/test_audit_reconciliation_mode.py +++ b/tests/test_audit_reconciliation_mode.py @@ -284,7 +284,7 @@ class TestTaskCapabilityMap(unittest.TestCase): required_permission("reconciliation_cleanup"), "gitea.branch.delete", ) - self.assertEqual(required_role("reconciliation_cleanup"), "author") + self.assertEqual(required_role("reconciliation_cleanup"), "reconciler") if __name__ == "__main__": diff --git a/tests/test_reconciler_cleanup_integration.py b/tests/test_reconciler_cleanup_integration.py new file mode 100644 index 0000000..7b3ef4a --- /dev/null +++ b/tests/test_reconciler_cleanup_integration.py @@ -0,0 +1,112 @@ +"""Integration tests for reconciler merged cleanup (#523).""" +import os +import sys +import unittest +from unittest.mock import patch, MagicMock + +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) + +import mcp_server +import task_capability_map +from audit_reconciliation_mode import clear_phase + +RECONCILER_PROFILE = { + "profile_name": "prgs-reconciler", + "context": "prgs", + "role": "reconciler", + "username": "sysadmin", + "base_url": "https://gitea.prgs.cc", + "allowed_operations": ["gitea.read", "gitea.pr.close", "gitea.pr.comment", "gitea.issue.comment"], + "forbidden_operations": [], +} + + +class TestReconcilerCleanupIntegration(unittest.TestCase): + def setUp(self): + clear_phase() + mcp_server._preflight_whoami_called = False + mcp_server._preflight_capability_called = False + mcp_server._preflight_resolved_role = None + mcp_server._preflight_resolved_task = None + mcp_server._preflight_whoami_violation = False + mcp_server._preflight_capability_violation = False + mcp_server._preflight_whoami_violation_files = [] + mcp_server._preflight_capability_violation_files = [] + self.mock_api = patch("gitea_auth.api_request").start() + self.mock_auth = patch( + "mcp_server.get_auth_header", return_value="token test" + ).start() + + def tearDown(self): + patch.stopall() + clear_phase() + mcp_server._IDENTITY_CACHE.clear() + + @patch.dict(os.environ, {"GITEA_PROFILE_NAME": "prgs-reconciler"}, clear=True) + @patch("mcp_server.get_profile", return_value=RECONCILER_PROFILE) + def test_reconciler_can_run_reconcile_merged_cleanups_directly(self, _profile): + mcp_server.record_preflight_check("whoami") + mcp_server.record_preflight_check("capability", resolved_role="reconciler", resolved_task="reconcile_merged_cleanups") + + # Mock closed PRs and open PRs returned by API + self.mock_api.side_effect = [ + [ # closed pulls + { + "number": 100, + "title": "merged feature", + "body": "Closes #100", + "merged": True, + "merged_at": "2026-07-06T12:00:00Z", + "merge_commit_sha": "deadbeef", + "head": {"ref": "feat/issue-100", "sha": "cafebabe"}, + } + ], + [], # open pulls + ] + + with patch("mcp_server._remote_branch_exists", return_value=True): + with patch("mcp_server.merged_cleanup_reconcile.is_head_ancestor_of_ref", return_value=True): + result = mcp_server.gitea_reconcile_merged_cleanups( + dry_run=True, + remote="prgs", + ) + self.assertTrue(result["success"]) + self.assertTrue(result["dry_run"]) + self.assertEqual(result["entries"][0]["issue_number"], 100) + + @patch.dict(os.environ, {"GITEA_PROFILE_NAME": "prgs-reconciler"}, clear=True) + @patch("mcp_server.get_profile", return_value=RECONCILER_PROFILE) + def test_reconciler_role_bypasses_branches_only_mutation_guard(self, _profile): + # We simulate verify_preflight_purity under reconciler role. + # It should succeed without raising RuntimeError even if CWD/workspace is the process root. + mcp_server.record_preflight_check("whoami") + mcp_server.record_preflight_check("capability", resolved_role="reconciler", resolved_task="reconcile_merged_cleanups") + + # Calling verify_preflight_purity directly + try: + mcp_server.verify_preflight_purity(remote="prgs", task="reconcile_merged_cleanups") + except RuntimeError as e: + self.fail(f"verify_preflight_purity raised RuntimeError unexpectedly: {e}") + + @patch.dict(os.environ, {"GITEA_PROFILE_NAME": "prgs-author", "GITEA_TEST_PORCELAIN": ""}, clear=True) + @patch("mcp_server.get_profile", return_value={ + "profile_name": "prgs-author", + "context": "prgs", + "role": "author", + "username": "jcwalker3", + "base_url": "https://gitea.prgs.cc", + "allowed_operations": ["gitea.read", "gitea.pr.create", "gitea.branch.create", "gitea.branch.push", "gitea.repo.commit"], + "forbidden_operations": [], + }) + def test_author_role_remains_blocked_on_root_checkout(self, _profile): + mcp_server.record_preflight_check("whoami") + mcp_server.record_preflight_check("capability", resolved_role="author", resolved_task="reconcile_merged_cleanups") + + with patch("mcp_server.PROJECT_ROOT", "/Users/jasonwalker/Development/Gitea-Tools"): + with self.assertRaises(RuntimeError) as ctx: + mcp_server.verify_preflight_purity(remote="prgs", task="reconcile_merged_cleanups") + self.assertIn("author mutation blocked: workspace is the stable control checkout", str(ctx.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/lease_loader.py b/webui/lease_loader.py index 717feef..548aec3 100644 --- a/webui/lease_loader.py +++ b/webui/lease_loader.py @@ -11,7 +11,8 @@ from urllib.parse import urlparse from gitea_auth import api_fetch_page, get_auth_header, repo_api_url from issue_claim_heartbeat import build_claim_inventory -from merged_cleanup_reconcile import ISSUE_LOCK_FILE, read_issue_lock +from merged_cleanup_reconcile import read_issue_lock +from issue_lock_provenance import ISSUE_LOCK_FILE from webui.project_registry import ProjectRecord, load_registry from webui.queue_loader import _extract_linked_issue, _fetch_issues, _fetch_prs From 95f77bb6485abc841ed999f6a7455cc41bdb8572 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Tue, 7 Jul 2026 14:23:18 -0400 Subject: [PATCH 2/4] feat: add MCP runtime health dashboard to web UI (Closes #430) Adds read-only /runtime and /api/runtime routes showing active profile, identity, config model, git sync vs remote master, shell health, workflow/schema hashes, and stale-runtime warnings with #420 guidance. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/webui-local-dev.md | 14 +- tests/test_webui_runtime_health.py | 88 ++++++++ tests/test_webui_skeleton.py | 7 +- webui/app.py | 17 +- webui/runtime_health.py | 320 +++++++++++++++++++++++++++++ webui/runtime_views.py | 92 +++++++++ 6 files changed, 529 insertions(+), 9 deletions(-) create mode 100644 tests/test_webui_runtime_health.py create mode 100644 webui/runtime_health.py create mode 100644 webui/runtime_views.py diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md index 96225b6..23d4c56 100644 --- a/docs/webui-local-dev.md +++ b/docs/webui-local-dev.md @@ -43,7 +43,8 @@ Optional environment variables: | `/api/projects` | JSON registry export | | `/prompts` | Prompt library with per-prompt copy buttons (#428) | | `/api/prompts` | JSON prompt export with workflow hashes | -| `/runtime` | Stub — MCP runtime health (#430) | +| `/runtime` | MCP runtime health and stale detection (#430) | +| `/api/runtime` | JSON runtime health export | | `/audit` | Stub — report audit paste (#431) | | `/worktrees` | Stub — hygiene dashboard (#432) | | `/leases` | Lease and collision visibility (#433) | @@ -91,8 +92,17 @@ in-progress claim inventory (#268), reviewer PR lease comments when present and duplicate local branches per issue. Links to collision-history backend issues (#267, #268, #400, #407) are included. No lease acquire/release from UI. +## Runtime health (#430) + +`/runtime` surfaces read-only MCP/runtime diagnostics for the default registry +project: active profile and role kind, authenticated identity (when credentials +are available), config model/mode, local vs remote `master` SHA sync, shell +health, workflow/schema SHA-256 hashes, and stale-runtime warnings when the +checkout is behind merged safety-gate changes. Restart guidance links to #420; +no tokens or MCP restart actions are exposed. + ## Tests ```bash -pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py -q +pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py tests/test_webui_runtime_health.py -q ``` \ No newline at end of file diff --git a/tests/test_webui_runtime_health.py b/tests/test_webui_runtime_health.py new file mode 100644 index 0000000..9fda3ee --- /dev/null +++ b/tests/test_webui_runtime_health.py @@ -0,0 +1,88 @@ +"""Tests for web UI runtime health dashboard (#430).""" +import sys +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from starlette.testclient import TestClient + +from webui.app import create_app +from webui.runtime_health import _role_kind, load_runtime_snapshot, snapshot_to_dict + + +def _mock_identity(_host: str): + return "jcwalker3", None + + +def _mock_git_sync(_repo: Path, _remote: str, _branch: str): + return "localsha123456", "remoteshaabcdef", 2 + + +class TestRuntimeHealthLoader(unittest.TestCase): + def test_role_kind_author(self): + allowed = ["gitea.pr.create", "gitea.branch.push", "gitea.read"] + forbidden = ["gitea.pr.merge", "gitea.pr.approve"] + self.assertEqual(_role_kind(allowed, forbidden), "author") + + def test_snapshot_with_mocks(self): + snapshot = load_runtime_snapshot( + resolve_username=_mock_identity, + git_sync=_mock_git_sync, + ) + self.assertEqual(snapshot.project_id, "gitea-tools") + self.assertEqual(snapshot.authenticated_username, "jcwalker3") + self.assertEqual(snapshot.commits_behind_master, 2) + self.assertIsNotNone(snapshot.stale_runtime_warning) + self.assertGreaterEqual(len(snapshot.workflow_hashes), 3) + self.assertGreaterEqual(len(snapshot.schema_hashes), 2) + self.assertIn("shell_use_allowed", snapshot.shell_health) + + def test_snapshot_dict_export(self): + snapshot = load_runtime_snapshot( + resolve_username=_mock_identity, + git_sync=_mock_git_sync, + ) + data = snapshot_to_dict(snapshot) + self.assertEqual(data["authenticated_username"], "jcwalker3") + self.assertEqual(data["commits_behind_master"], 2) + self.assertTrue(data["stale_runtime_warning"]) + + +class TestRuntimeRoutes(unittest.TestCase): + def setUp(self): + self.client = TestClient(create_app()) + self.snapshot = load_runtime_snapshot( + resolve_username=_mock_identity, + git_sync=_mock_git_sync, + ) + self._patch = mock.patch( + "webui.app.load_runtime_snapshot", + return_value=self.snapshot, + ) + self._patch.start() + + def tearDown(self): + self._patch.stop() + + def test_runtime_page_renders_profile_and_warning(self): + response = self.client.get("/runtime") + self.assertEqual(response.status_code, 200) + self.assertIn("Runtime health", response.text) + self.assertIn("Active profile", response.text) + self.assertIn("Stale runtime warning", response.text) + self.assertIn("Workflow hashes", response.text) + self.assertNotIn("child issue", response.text.lower()) + + def test_api_runtime_json(self): + response = self.client.get("/api/runtime") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["project_id"], "gitea-tools") + self.assertEqual(data["role_kind"], self.snapshot.role_kind) + self.assertEqual(data["commits_behind_master"], 2) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_webui_skeleton.py b/tests/test_webui_skeleton.py index 953b368..e5b71b6 100644 --- a/tests/test_webui_skeleton.py +++ b/tests/test_webui_skeleton.py @@ -29,8 +29,13 @@ class TestWebuiSkeleton(unittest.TestCase): self.assertIn("Operator console", response.text) self.assertIn("Read-only MVP", response.text) + def test_runtime_is_implemented(self): + response = self.client.get("/runtime") + self.assertEqual(response.status_code, 200) + self.assertIn("Runtime health", response.text) + def test_route_stubs_render(self): - for path in ("/runtime", "/audit"): + for path in ("/audit",): with self.subTest(path=path): response = self.client.get(path) self.assertEqual(response.status_code, 200) diff --git a/webui/app.py b/webui/app.py index 4f4cb0f..fb1aab2 100644 --- a/webui/app.py +++ b/webui/app.py @@ -16,8 +16,10 @@ from webui.prompt_library import find_prompt, library_to_dict from webui.prompt_views import render_prompt_detail, render_prompts_page from webui.lease_loader import load_lease_snapshot, snapshot_to_dict as lease_snapshot_to_dict from webui.lease_views import render_leases_page -from webui.queue_loader import load_queue_snapshot, snapshot_to_dict +from webui.queue_loader import load_queue_snapshot, snapshot_to_dict as queue_snapshot_to_dict from webui.queue_views import render_queue_page +from webui.runtime_health import load_runtime_snapshot, snapshot_to_dict as runtime_snapshot_to_dict +from webui.runtime_views import render_runtime_page _READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) @@ -63,7 +65,7 @@ async def queue(_request: Request) -> HTMLResponse: async def api_queue(_request: Request) -> JSONResponse: - return JSONResponse(snapshot_to_dict(load_queue_snapshot())) + return JSONResponse(queue_snapshot_to_dict(load_queue_snapshot())) async def projects(_request: Request) -> HTMLResponse: @@ -122,10 +124,12 @@ async def api_prompts(_request: Request) -> JSONResponse: async def runtime(_request: Request) -> HTMLResponse: - return _stub_page( - "Runtime", - "Runtime health will report MCP profile, preflight, and stale-server signals.", - ) + snapshot = load_runtime_snapshot() + return HTMLResponse(render_page(title="Runtime", body_html=render_runtime_page(snapshot))) + + +async def api_runtime(_request: Request) -> JSONResponse: + return JSONResponse(runtime_snapshot_to_dict(load_runtime_snapshot())) async def audit(_request: Request) -> HTMLResponse: @@ -176,6 +180,7 @@ def create_app() -> Starlette: Route("/prompts/{prompt_id}", prompt_detail, methods=["GET"]), Route("/api/prompts", api_prompts, methods=["GET"]), Route("/runtime", runtime, methods=["GET"]), + Route("/api/runtime", api_runtime, methods=["GET"]), Route("/audit", audit, methods=["GET"]), Route("/worktrees", worktrees, methods=["GET"]), Route("/leases", leases, methods=["GET"]), diff --git a/webui/runtime_health.py b/webui/runtime_health.py new file mode 100644 index 0000000..8f3b5d6 --- /dev/null +++ b/webui/runtime_health.py @@ -0,0 +1,320 @@ +"""Read-only MCP/runtime health snapshot for the web UI (#430).""" + +from __future__ import annotations + +import hashlib +import os +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable +from urllib.parse import urlparse + +import gitea_config +import reconciler_profile +from gitea_auth import REMOTES, api_request, get_auth_header, get_profile, gitea_url + +from native_mcp_preference import shell_health_status + +from webui.project_registry import ProjectRecord, load_registry + +_RESTART_DOCS = "docs/llm-workflow-runbooks.md#issue-420-mcp-hot-reload" +_HOT_RELOAD_ISSUE = "#420" + + +@dataclass(frozen=True) +class FileHash: + label: str + path: str + sha256: str | None + error: str | None = None + + +@dataclass(frozen=True) +class RuntimeSnapshot: + project_id: str + repo_root: str + remote: str + host: str + profile_name: str + role_kind: str + config_model: str + profile_mode: str + profile_source: str + authenticated_username: str | None + identity_error: str | None + repo_sha: str | None + remote_master_sha: str | None + commits_behind_master: int | None + stale_runtime_warning: str | None + shell_health: dict[str, Any] + workflow_hashes: tuple[FileHash, ...] + schema_hashes: tuple[FileHash, ...] + restart_guidance: str + fetch_error: str | None = None + + +def _repo_root() -> Path: + override = (os.environ.get("WEBUI_REPO_ROOT") or "").strip() + if override: + return Path(override).resolve() + return Path(__file__).resolve().parent.parent + + +def _host_from_url(remote_host: str) -> str: + parsed = urlparse(remote_host.strip()) + return parsed.netloc or remote_host.strip().rstrip("/") + + +def _remote_name_for_host(host: str) -> str: + for name, profile in REMOTES.items(): + if profile.get("host") == host: + return name + return "custom" + + +def _config_model(config: dict | None) -> str: + if config is None: + return "env-only" + version = config.get("version") + if version == 1: + return "v1" + if version == 2: + if "contexts" in config or config.get("shape") == "contexts": + return "v2-contexts" + return "v2-environments" + return f"unknown-version-{version}" + + +def _profile_source() -> str: + if os.environ.get("GITEA_PROFILE_NAME"): + return "env var" + if gitea_config.selected_profile_name(): + return "config file profile" + return "default" + + +def _role_kind(allowed: list[str], forbidden: list[str]) -> str: + if reconciler_profile.is_reconciler_profile(allowed, forbidden): + return "reconciler" + + def can(op: str) -> bool: + return gitea_config.check_operation(op, allowed, forbidden)[0] + + review = can("gitea.pr.approve") or can("gitea.pr.merge") + author = can("gitea.pr.create") or can("gitea.branch.push") + reconciler = can("gitea.pr.close") and not review and not author + if review and author: + return "mixed" + if review: + return "reviewer" + if reconciler: + return "reconciler" + if author: + return "author" + return "limited" + + +def _sha256_file(path: Path) -> FileHash: + label = path.name + try: + digest = hashlib.sha256(path.read_bytes()).hexdigest() + return FileHash(label=label, path=str(path), sha256=digest) + except OSError as exc: + return FileHash(label=label, path=str(path), sha256=None, error=str(exc)) + + +def _run_git(repo: Path, *args: str) -> str | None: + try: + completed = subprocess.run( + ["git", *args], + cwd=repo, + check=True, + capture_output=True, + text=True, + ) + return completed.stdout.strip() or None + except (OSError, subprocess.CalledProcessError): + return None + + +def _git_sync_status(repo: Path, remote: str, branch: str) -> tuple[str | None, str | None, int | None]: + local_sha = _run_git(repo, "rev-parse", "HEAD") + remote_sha = _run_git(repo, "rev-parse", f"{remote}/{branch}") + behind = None + if local_sha and remote_sha and local_sha != remote_sha: + count = _run_git(repo, "rev-list", "--count", f"HEAD..{remote}/{branch}") + if count is not None and count.isdigit(): + behind = int(count) + return local_sha, remote_sha, behind + + +def _resolve_username(host: str) -> tuple[str | None, str | None]: + auth = get_auth_header(host) + if not auth: + return None, f"Gitea credentials unavailable for {host}" + try: + data = api_request("GET", gitea_url(host, "/api/v1/user"), auth) + except Exception as exc: # noqa: BLE001 — operator-visible identity errors + return None, f"Identity lookup failed: {exc}" + username = (data or {}).get("login") + if not username: + return None, "Authenticated identity could not be resolved" + return str(username), None + + +def _stale_warning(commits_behind: int | None) -> str | None: + if commits_behind is None or commits_behind <= 0: + return None + return ( + f"Local checkout is {commits_behind} commit(s) behind remote master. " + "Merged safety-gate changes may require an MCP server restart or profile " + f"reload — see {_HOT_RELOAD_ISSUE} / {_RESTART_DOCS}." + ) + + +def load_runtime_snapshot( + project_id: str | None = None, + *, + resolve_username: Callable[[str], tuple[str | None, str | None]] | None = None, + git_sync: Callable[[Path, str, str], tuple[str | None, str | None, int | None]] | None = None, +) -> RuntimeSnapshot: + """Build a read-only runtime health snapshot for the default registry project.""" + registry = load_registry() + project: ProjectRecord | None = None + if project_id: + for entry in registry.projects: + if entry.id == project_id: + project = entry + break + else: + project = registry.projects[0] if registry.projects else None + + if project is None: + return RuntimeSnapshot( + project_id=project_id or "", + repo_root=str(_repo_root()), + remote="", + host="", + profile_name="", + role_kind="", + config_model="", + profile_mode="", + profile_source="", + authenticated_username=None, + identity_error="project not found in registry", + repo_sha=None, + remote_master_sha=None, + commits_behind_master=None, + stale_runtime_warning=None, + shell_health={}, + workflow_hashes=(), + schema_hashes=(), + restart_guidance=_RESTART_DOCS, + fetch_error="project not found in registry", + ) + + repo = _repo_root() + host = _host_from_url(project.remote_host) + remote = _remote_name_for_host(host) + profile = get_profile() + allowed = profile.get("allowed_operations") or [] + forbidden = profile.get("forbidden_operations") or [] + config = None + try: + config = gitea_config.load_config() + except gitea_config.ConfigError as exc: + return RuntimeSnapshot( + project_id=project.id, + repo_root=str(repo), + remote=remote, + host=host, + profile_name=profile.get("profile_name") or "", + role_kind=_role_kind(allowed, forbidden), + config_model="config-error", + profile_mode="unknown", + profile_source=_profile_source(), + authenticated_username=None, + identity_error=str(exc), + repo_sha=None, + remote_master_sha=None, + commits_behind_master=None, + stale_runtime_warning=None, + shell_health=shell_health_status(), + workflow_hashes=(), + schema_hashes=(), + restart_guidance=_RESTART_DOCS, + fetch_error=str(exc), + ) + + identity_fn = resolve_username or _resolve_username + username, identity_error = identity_fn(host) + + git_fn = git_sync or _git_sync_status + remote_ref = "prgs" if remote == "prgs" else "origin" + local_sha, remote_sha, behind = git_fn(repo, remote_ref, project.default_branch) + + workflow_hashes = tuple( + _sha256_file(repo / rel_path) + for rel_path in project.workflow_paths.values() + ) + schema_hashes = tuple( + _sha256_file(repo / rel_path) + for rel_path in project.schema_paths.values() + ) + + switching = gitea_config.is_runtime_switching_enabled() + return RuntimeSnapshot( + project_id=project.id, + repo_root=str(repo), + remote=remote, + host=host, + profile_name=str(profile.get("profile_name") or ""), + role_kind=_role_kind(allowed, forbidden), + config_model=_config_model(config), + profile_mode="dynamic-profile" if switching else "static-profile", + profile_source=_profile_source(), + authenticated_username=username, + identity_error=identity_error, + repo_sha=local_sha, + remote_master_sha=remote_sha, + commits_behind_master=behind, + stale_runtime_warning=_stale_warning(behind), + shell_health=shell_health_status(), + workflow_hashes=workflow_hashes, + schema_hashes=schema_hashes, + restart_guidance=_RESTART_DOCS, + ) + + +def snapshot_to_dict(snapshot: RuntimeSnapshot) -> dict[str, Any]: + def _hash_row(item: FileHash) -> dict[str, Any]: + return { + "label": item.label, + "path": item.path, + "sha256": item.sha256, + "error": item.error, + } + + return { + "project_id": snapshot.project_id, + "repo_root": snapshot.repo_root, + "remote": snapshot.remote, + "host": snapshot.host, + "profile_name": snapshot.profile_name, + "role_kind": snapshot.role_kind, + "config_model": snapshot.config_model, + "profile_mode": snapshot.profile_mode, + "profile_source": snapshot.profile_source, + "authenticated_username": snapshot.authenticated_username, + "identity_error": snapshot.identity_error, + "repo_sha": snapshot.repo_sha, + "remote_master_sha": snapshot.remote_master_sha, + "commits_behind_master": snapshot.commits_behind_master, + "stale_runtime_warning": snapshot.stale_runtime_warning, + "shell_health": snapshot.shell_health, + "workflow_hashes": [_hash_row(item) for item in snapshot.workflow_hashes], + "schema_hashes": [_hash_row(item) for item in snapshot.schema_hashes], + "restart_guidance": snapshot.restart_guidance, + "fetch_error": snapshot.fetch_error, + } \ No newline at end of file diff --git a/webui/runtime_views.py b/webui/runtime_views.py new file mode 100644 index 0000000..b057248 --- /dev/null +++ b/webui/runtime_views.py @@ -0,0 +1,92 @@ +"""HTML views for MCP runtime health (#430).""" + +from __future__ import annotations + +import html + +from webui.runtime_health import FileHash, RuntimeSnapshot + + +def _hash_rows(items: tuple[FileHash, ...]) -> str: + if not items: + return "

No hashes available.

" + rows = [] + for item in items: + digest = item.sha256 or item.error or "unavailable" + rows.append( + "" + f"{html.escape(item.label)}" + f"{html.escape(item.path)}" + f"{html.escape(digest[:16] if item.sha256 else digest)}" + "" + ) + return ( + "" + "" + "" + f"{''.join(rows)}
ArtifactPathSHA-256
" + ) + + +def render_runtime_page(snapshot: RuntimeSnapshot) -> str: + error_block = "" + if snapshot.fetch_error: + error_block = ( + '

Runtime snapshot incomplete: ' + f"{html.escape(snapshot.fetch_error)}

" + ) + + identity = snapshot.authenticated_username or "unresolved" + if snapshot.identity_error: + identity = f"unresolved ({snapshot.identity_error})" + + stale_block = "" + if snapshot.stale_runtime_warning: + stale_block = ( + '

Stale runtime warning: ' + f"{html.escape(snapshot.stale_runtime_warning)}

" + ) + + shell = snapshot.shell_health or {} + shell_summary = ( + f"shell_use_allowed={shell.get('shell_use_allowed')} · " + f"failures={shell.get('consecutive_spawn_failures')} · " + f"hard_stopped={shell.get('hard_stopped')}" + ) + + return ( + "

Runtime health

" + f"

Project {html.escape(snapshot.project_id)} · " + f"host {html.escape(snapshot.host)} · " + f"repo root {html.escape(snapshot.repo_root)}

" + f"{error_block}" + f"{stale_block}" + "

Active profile

" + "" + f"" + f"" + f"" + f"" + f"" + "
Profile{html.escape(snapshot.profile_name)}
Role kind{html.escape(snapshot.role_kind)}
Identity{html.escape(identity)}
Config model{html.escape(snapshot.config_model)}
Profile mode{html.escape(snapshot.profile_mode)} " + f"({html.escape(snapshot.profile_source)})
" + "

Server code sync

" + "" + f"" + f"" + f"" + f"" + "
Local HEAD{html.escape(snapshot.repo_sha or 'unknown')}
Remote {html.escape(snapshot.remote)}/{html.escape('master')}{html.escape(snapshot.remote_master_sha or 'unknown')}
Commits behind{snapshot.commits_behind_master if snapshot.commits_behind_master is not None else 'unknown'}
" + "

Shell health

" + f"

{html.escape(shell_summary)}

" + f"

{html.escape(str(shell.get('safe_next_action') or ''))}

" + "

Workflow hashes

" + f"{_hash_rows(snapshot.workflow_hashes)}" + "

Schema hashes

" + f"{_hash_rows(snapshot.schema_hashes)}" + "

Restart / reload

" + "

MVP is read-only — restart MCP servers from your IDE/operator " + "workflow. Related issue: #420. Guidance: " + f"{html.escape(snapshot.restart_guidance)}

" + "

This page does not expose tokens or perform MCP restarts.

" + ) \ No newline at end of file From ffa1ff95cc2cf8d77ca2e6c90d903b2407fa7dc4 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 8 Jul 2026 23:53:25 -0400 Subject: [PATCH 3/4] fix: import ISSUE_LOCK_FILE from issue_lock_provenance The conflict-resolution rebase incorrectly kept ISSUE_LOCK_FILE in the merged_cleanup_reconcile import, but that symbol was moved to issue_lock_provenance on master. This broke webui import collection. Fixes the reviewer-reported regression on PR #448. --- webui/lease_loader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webui/lease_loader.py b/webui/lease_loader.py index 548aec3..318a46f 100644 --- a/webui/lease_loader.py +++ b/webui/lease_loader.py @@ -11,8 +11,8 @@ from urllib.parse import urlparse from gitea_auth import api_fetch_page, get_auth_header, repo_api_url from issue_claim_heartbeat import build_claim_inventory -from merged_cleanup_reconcile import read_issue_lock from issue_lock_provenance import ISSUE_LOCK_FILE +from merged_cleanup_reconcile import read_issue_lock from webui.project_registry import ProjectRecord, load_registry from webui.queue_loader import _extract_linked_issue, _fetch_issues, _fetch_prs From e5bf8c96474686b5759a2fded0b42159e9d096fb Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Thu, 9 Jul 2026 00:09:47 -0400 Subject: [PATCH 4/4] fix: strengthen reconciler merged cleanup tests and runbook (#523) Make the preflight bypass test actually exercise purity (not test short-circuit), cover unmerged/open-head fail-closed cleanup, and document post-merge cleanup ownership for prgs-reconciler so authors are not required for that path. --- docs/llm-workflow-runbooks.md | 21 ++ tests/test_reconciler_cleanup_integration.py | 225 ++++++++++++++++--- 2 files changed, 209 insertions(+), 37 deletions(-) diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md index ed1660f..ce42f39 100644 --- a/docs/llm-workflow-runbooks.md +++ b/docs/llm-workflow-runbooks.md @@ -738,6 +738,27 @@ merger handoff. - **Prompt (normal):** `After verifying master contains the merge of PR #N using post-merge file-presence verification, close issue #M and delete the merged branch. Include verification details in the report.` - **Prompt (reconcile):** `Reconcile closed-not-merged PR #N by verifying if its content landed on master.` +### Post-merge merged cleanup ownership (#523) + +Post-merge **local worktree / remote branch cleanup** is **reconciler** work, not +author work. Do not switch from `prgs-reconciler` to `prgs-author` only to run +`gitea_reconcile_merged_cleanups`. + +- **Profile:** `prgs-reconciler` (task `reconcile_merged_cleanups` / + `reconciliation_cleanup`). +- **Namespace:** reconciler MCP server; stable control checkout is allowed for + this role (branches-only author guard does not apply). +- **Steps:** + 1. `gitea_whoami` + `gitea_resolve_task_capability(task="reconcile_merged_cleanups")`. + 2. Dry-run first: `gitea_reconcile_merged_cleanups(dry_run=True)`. + 3. Execute only after audit/authorization gates when remote branch delete or + worktree removal is required (`dry_run=False`, `execute_confirmed=True`, + and `gitea.branch.delete` when deleting remotes). +- **Fail closed:** unmerged/open heads, mismatched worktrees, and non-merged + closed PRs must not be cleaned. +- **Reports:** label cleanup actions as reconciler cleanup (not author mutation). +- **Prompt:** `As prgs-reconciler, dry-run then execute gitea_reconcile_merged_cleanups for recently merged PRs without switching to prgs-author.` + ### Stop on blocker - **Any profile.** If a required gate cannot be satisfied — identity diff --git a/tests/test_reconciler_cleanup_integration.py b/tests/test_reconciler_cleanup_integration.py index 7b3ef4a..7637602 100644 --- a/tests/test_reconciler_cleanup_integration.py +++ b/tests/test_reconciler_cleanup_integration.py @@ -2,13 +2,14 @@ import os import sys import unittest -from unittest.mock import patch, MagicMock +from unittest.mock import patch sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) import mcp_server import task_capability_map from audit_reconciliation_mode import clear_phase +from task_capability_map import required_role RECONCILER_PROFILE = { "profile_name": "prgs-reconciler", @@ -16,10 +17,33 @@ RECONCILER_PROFILE = { "role": "reconciler", "username": "sysadmin", "base_url": "https://gitea.prgs.cc", - "allowed_operations": ["gitea.read", "gitea.pr.close", "gitea.pr.comment", "gitea.issue.comment"], + "allowed_operations": [ + "gitea.read", + "gitea.pr.close", + "gitea.pr.comment", + "gitea.issue.comment", + ], "forbidden_operations": [], } +AUTHOR_PROFILE = { + "profile_name": "prgs-author", + "context": "prgs", + "role": "author", + "username": "jcwalker3", + "base_url": "https://gitea.prgs.cc", + "allowed_operations": [ + "gitea.read", + "gitea.pr.create", + "gitea.branch.create", + "gitea.branch.push", + "gitea.repo.commit", + ], + "forbidden_operations": [], +} + +CONTROL_ROOT = "/Users/jasonwalker/Development/Gitea-Tools" + class TestReconcilerCleanupIntegration(unittest.TestCase): def setUp(self): @@ -32,6 +56,7 @@ class TestReconcilerCleanupIntegration(unittest.TestCase): mcp_server._preflight_capability_violation = False mcp_server._preflight_whoami_violation_files = [] mcp_server._preflight_capability_violation_files = [] + mcp_server._preflight_capability_baseline_porcelain = "" self.mock_api = patch("gitea_auth.api_request").start() self.mock_auth = patch( "mcp_server.get_auth_header", return_value="token test" @@ -42,15 +67,26 @@ class TestReconcilerCleanupIntegration(unittest.TestCase): clear_phase() mcp_server._IDENTITY_CACHE.clear() + def test_capability_map_routes_merged_cleanup_to_reconciler(self): + self.assertEqual(required_role("reconcile_merged_cleanups"), "reconciler") + self.assertEqual(required_role("reconciliation_cleanup"), "reconciler") + self.assertEqual( + task_capability_map.required_permission("reconcile_merged_cleanups"), + "gitea.read", + ) + @patch.dict(os.environ, {"GITEA_PROFILE_NAME": "prgs-reconciler"}, clear=True) @patch("mcp_server.get_profile", return_value=RECONCILER_PROFILE) def test_reconciler_can_run_reconcile_merged_cleanups_directly(self, _profile): mcp_server.record_preflight_check("whoami") - mcp_server.record_preflight_check("capability", resolved_role="reconciler", resolved_task="reconcile_merged_cleanups") - - # Mock closed PRs and open PRs returned by API + mcp_server.record_preflight_check( + "capability", + resolved_role="reconciler", + resolved_task="reconcile_merged_cleanups", + ) + self.mock_api.side_effect = [ - [ # closed pulls + [ # closed pulls page { "number": 100, "title": "merged feature", @@ -59,53 +95,168 @@ class TestReconcilerCleanupIntegration(unittest.TestCase): "merged_at": "2026-07-06T12:00:00Z", "merge_commit_sha": "deadbeef", "head": {"ref": "feat/issue-100", "sha": "cafebabe"}, - } + }, + { + # closed but NOT merged — must not become a cleanup entry + "number": 101, + "title": "abandoned", + "body": "Closes #101", + "merged": False, + "merged_at": None, + "head": {"ref": "feat/issue-101", "sha": "badcafe"}, + }, ], [], # open pulls ] with patch("mcp_server._remote_branch_exists", return_value=True): - with patch("mcp_server.merged_cleanup_reconcile.is_head_ancestor_of_ref", return_value=True): + with patch( + "mcp_server.merged_cleanup_reconcile.is_head_ancestor_of_ref", + return_value=True, + ): result = mcp_server.gitea_reconcile_merged_cleanups( dry_run=True, remote="prgs", ) self.assertTrue(result["success"]) self.assertTrue(result["dry_run"]) - self.assertEqual(result["entries"][0]["issue_number"], 100) + entry_numbers = [e["issue_number"] for e in result["entries"]] + self.assertEqual(entry_numbers, [100]) + self.assertNotIn(101, entry_numbers) + + @patch.dict( + os.environ, + { + "GITEA_PROFILE_NAME": "prgs-reconciler", + # Force preflight purity to evaluate (disable test short-circuit). + "GITEA_TEST_PORCELAIN": "", + }, + clear=True, + ) + @patch("mcp_server.get_profile", return_value=RECONCILER_PROFILE) + def test_reconciler_role_bypasses_branches_only_mutation_guard(self, _profile): + mcp_server.record_preflight_check("whoami") + mcp_server.record_preflight_check( + "capability", + resolved_role="reconciler", + resolved_task="reconcile_merged_cleanups", + ) + + with patch("mcp_server.PROJECT_ROOT", CONTROL_ROOT): + with patch( + "mcp_server.root_checkout_guard.resolve_remote_master_sha", + return_value="abc123", + ): + with patch( + "mcp_server.issue_lock_worktree.read_worktree_git_state", + return_value={ + "current_branch": "master", + "head_sha": "abc123", + "porcelain_status": "", + }, + ): + try: + mcp_server.verify_preflight_purity( + remote="prgs", + task="reconcile_merged_cleanups", + ) + except RuntimeError as e: + self.fail( + "verify_preflight_purity raised RuntimeError " + f"unexpectedly for reconciler: {e}" + ) + + @patch.dict( + os.environ, + {"GITEA_PROFILE_NAME": "prgs-author", "GITEA_TEST_PORCELAIN": ""}, + clear=True, + ) + @patch("mcp_server.get_profile", return_value=AUTHOR_PROFILE) + def test_author_role_remains_blocked_on_root_checkout(self, _profile): + mcp_server.record_preflight_check("whoami") + mcp_server.record_preflight_check( + "capability", + resolved_role="author", + resolved_task="reconcile_merged_cleanups", + ) + + with patch("mcp_server.PROJECT_ROOT", CONTROL_ROOT): + with patch( + "mcp_server.root_checkout_guard.resolve_remote_master_sha", + return_value="abc123", + ): + with patch( + "mcp_server.issue_lock_worktree.read_worktree_git_state", + return_value={ + "current_branch": "master", + "head_sha": "abc123", + "porcelain_status": "", + }, + ): + with self.assertRaises(RuntimeError) as ctx: + mcp_server.verify_preflight_purity( + remote="prgs", + task="reconcile_merged_cleanups", + ) + message = str(ctx.exception) + self.assertTrue( + "stable control checkout" in message + or "author mutation blocked" in message + or "branches/" in message, + msg=message, + ) @patch.dict(os.environ, {"GITEA_PROFILE_NAME": "prgs-reconciler"}, clear=True) @patch("mcp_server.get_profile", return_value=RECONCILER_PROFILE) - def test_reconciler_role_bypasses_branches_only_mutation_guard(self, _profile): - # We simulate verify_preflight_purity under reconciler role. - # It should succeed without raising RuntimeError even if CWD/workspace is the process root. + def test_open_unmerged_pr_heads_are_not_safe_cleanup_targets(self, _profile): + """AC5: active/unmerged author work must remain blocked from cleanup.""" mcp_server.record_preflight_check("whoami") - mcp_server.record_preflight_check("capability", resolved_role="reconciler", resolved_task="reconcile_merged_cleanups") - - # Calling verify_preflight_purity directly - try: - mcp_server.verify_preflight_purity(remote="prgs", task="reconcile_merged_cleanups") - except RuntimeError as e: - self.fail(f"verify_preflight_purity raised RuntimeError unexpectedly: {e}") + mcp_server.record_preflight_check( + "capability", + resolved_role="reconciler", + resolved_task="reconcile_merged_cleanups", + ) - @patch.dict(os.environ, {"GITEA_PROFILE_NAME": "prgs-author", "GITEA_TEST_PORCELAIN": ""}, clear=True) - @patch("mcp_server.get_profile", return_value={ - "profile_name": "prgs-author", - "context": "prgs", - "role": "author", - "username": "jcwalker3", - "base_url": "https://gitea.prgs.cc", - "allowed_operations": ["gitea.read", "gitea.pr.create", "gitea.branch.create", "gitea.branch.push", "gitea.repo.commit"], - "forbidden_operations": [], - }) - def test_author_role_remains_blocked_on_root_checkout(self, _profile): - mcp_server.record_preflight_check("whoami") - mcp_server.record_preflight_check("capability", resolved_role="author", resolved_task="reconcile_merged_cleanups") - - with patch("mcp_server.PROJECT_ROOT", "/Users/jasonwalker/Development/Gitea-Tools"): - with self.assertRaises(RuntimeError) as ctx: - mcp_server.verify_preflight_purity(remote="prgs", task="reconcile_merged_cleanups") - self.assertIn("author mutation blocked: workspace is the stable control checkout", str(ctx.exception)) + open_pr = { + "number": 200, + "title": "active work", + "body": "Closes #200", + "merged": False, + "state": "open", + "head": {"ref": "feat/issue-200", "sha": "livehead1"}, + } + # Same head branch still open on another PR while a closed/merged PR + # used the same branch name historically — open head must block delete. + closed_merged = { + "number": 199, + "title": "older merge same branch name", + "body": "Closes #199", + "merged": True, + "merged_at": "2026-07-01T12:00:00Z", + "merge_commit_sha": "deadbeef", + "head": {"ref": "feat/issue-200", "sha": "oldhead99"}, + } + self.mock_api.side_effect = [ + [closed_merged], # closed + [open_pr], # open + ] + + with patch("mcp_server._remote_branch_exists", return_value=True): + with patch( + "mcp_server.merged_cleanup_reconcile.is_head_ancestor_of_ref", + return_value=True, + ): + result = mcp_server.gitea_reconcile_merged_cleanups( + dry_run=True, + remote="prgs", + ) + self.assertTrue(result["success"]) + self.assertEqual(len(result["entries"]), 1) + remote_assessment = result["entries"][0].get("remote_branch") or {} + self.assertFalse( + remote_assessment.get("safe_to_delete_remote"), + msg=f"open head must block remote delete: {remote_assessment}", + ) if __name__ == "__main__":