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/docs/mcp-menu.md b/docs/mcp-menu.md index 0b028f9..e028a40 100644 --- a/docs/mcp-menu.md +++ b/docs/mcp-menu.md @@ -41,7 +41,7 @@ The script must be executable (`chmod +x mcp-menu.sh`). It uses bash with |--------|-------------| | Project status / root checkout health | Shows cwd, branch, `git status --short --branch`, HEAD SHA, `prgs/master` SHA, and warnings when the root checkout is dirty or off `master`. | | Author workflow prompts | Ready-to-copy prompts for issue work, conflict-fix sessions, and root checkout recovery. | -| Reviewer workflow prompts | PR review prompt (review-only; no merge). | +| Reviewer workflow prompts | Standard PR review prompt, and a skip-already-reviewed-stale-`REQUEST_CHANGES` prompt that hands off to the author without a duplicate terminal mutation (review-only; no merge). | | Merger workflow prompts | PR merge prompt (merge gates and explicit approval). | | Reconciler workflow prompts | Already-landed / closed PR reconciliation prompt. | | Onboarding new project | Checklist prompt for adding a repository to the MCP workflow. | diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md index f7a1e27..f4542cd 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` | Stub — lease visibility (#433) | @@ -103,9 +104,18 @@ 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_gated_actions.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 -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/gitea_mcp_server.py b/gitea_mcp_server.py index 078db67..9c84ead 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -828,6 +828,13 @@ import audit_reconciliation_mode # noqa: E402 import review_merge_state_machine # noqa: E402 import pr_work_lease # noqa: E402 import native_mcp_preference # noqa: E402 +import master_parity_gate # noqa: E402 + +# Master-parity baseline (#420): the commit this server process started at. +# Captured once so that, at mutation time, we can detect when the on-disk +# master has advanced past the running code and fail closed until restart. +# Read-only operations are never blocked by staleness. +_STARTUP_PARITY = master_parity_gate.capture_startup_parity(PROJECT_ROOT) import worktree_cleanup_audit # noqa: E402 @@ -5585,15 +5592,41 @@ def _try_auto_switch_for_operation(op: str, host: str | None = None) -> bool: return False +def _current_master_parity() -> dict: + """Assess this process's code against the on-disk master HEAD (#420).""" + current_head = master_parity_gate.read_git_head(PROJECT_ROOT) + return master_parity_gate.assess_master_parity(_STARTUP_PARITY, current_head) + + +def _master_parity_block(op: str) -> list[str]: + """Fail-closed staleness reasons for a mutation *op* (#420). + + Read-only operations (``gitea.read``) are never blocked: a stale server can + still be inspected. Any other (mutating) operation is refused when the + on-disk master has definitively advanced past the running code, since newly + merged capability gates would not yet be loaded in memory. + """ + if op == "gitea.read": + return [] + return master_parity_gate.parity_block_reasons(_current_master_parity()) + + def _profile_operation_gate(op: str) -> list[str]: - """Profile permission check for a single gated operation (#126, #216). + """Profile permission check for a single gated operation (#126, #216, #420). Issue discussion comments are gated separately from the gitea.pr.* review/merge family: listing requires ``gitea.read``, creating requires ``gitea.issue.comment``. Closing a PR requires the distinct ``gitea.pr.close`` (#216). Returns a list of block reasons (empty = allowed); an unreadable profile fails closed. + + A mutating operation is additionally refused when the server code is stale + relative to master (#420), so a long-running process cannot bypass a + capability gate that has since been merged. """ + stale_reasons = _master_parity_block(op) + if stale_reasons: + return stale_reasons try: profile = get_profile() except Exception as exc: @@ -7697,6 +7730,24 @@ def gitea_get_runtime_context( PROJECT_ROOT), } + parity = _current_master_parity() + result["master_parity"] = { + "in_parity": parity["in_parity"], + "stale": parity["stale"], + "restart_required": parity["restart_required"], + "startup_head": parity["startup_head"], + "current_head": parity["current_head"], + "summary": master_parity_gate.format_parity(parity), + "mutation_gate_enforced": not master_parity_gate.gate_disabled(), + } + if parity["stale"] and not master_parity_gate.gate_disabled(): + safe_next_action = ( + "Server code is stale relative to master; restart the Gitea MCP " + "server to load current capability gates before mutating. " + f"({master_parity_gate.format_parity(parity)})" + ) + result["safe_next_action"] = safe_next_action + if reveal and h: result["server"] = gitea_url(h, "").rstrip("/") @@ -7704,6 +7755,43 @@ def gitea_get_runtime_context( @mcp.tool() +def gitea_assess_master_parity( + remote: str = "dadeschools", + host: str | None = None, +) -> dict: + """Read-only: is the running server code in parity with the on-disk master? + + The MCP server loads its capability-gate code into memory at startup; + ``master`` advancing (e.g. a newly merged security gate) does not take + effect until the process restarts. This tool compares the commit the + process started at against the current workspace ``HEAD`` and reports + whether a restart is required before mutations are safe again (#420). + + Never mutates and makes no network calls. Read-only operations are never + blocked by staleness; only mutating operations fail closed while stale. + + Returns: + dict with 'in_parity', 'stale', 'restart_required', 'startup_head', + 'current_head', 'mutation_gate_enforced', 'summary', 'reasons', and a + 'report' recovery payload when stale. + """ + parity = _current_master_parity() + enforced = not master_parity_gate.gate_disabled() + out = { + "in_parity": parity["in_parity"], + "stale": parity["stale"], + "restart_required": parity["restart_required"], + "determinable": parity["determinable"], + "startup_head": parity["startup_head"], + "current_head": parity["current_head"], + "mutation_gate_enforced": enforced, + "summary": master_parity_gate.format_parity(parity), + "reasons": parity["reasons"], + "process_root": PROJECT_ROOT, + } + if parity["stale"] and enforced: + out["report"] = master_parity_gate.parity_report(parity) + return out def gitea_record_pre_review_command( command: str, cwd: str | None = None, diff --git a/master_parity_gate.py b/master_parity_gate.py new file mode 100644 index 0000000..efb7314 --- /dev/null +++ b/master_parity_gate.py @@ -0,0 +1,168 @@ +"""Master-parity staleness gate (#420). + +The Gitea MCP server loads its capability-gate code and workflow logic into +memory when the process starts. When ``master`` advances -- for example a newly +merged security gate such as the branch-delete capability gate (#408/#410) -- +the running process keeps executing the *old* code until it is restarted. A +stale server can therefore still perform a mutation that the updated codebase +would forbid. + +Runtime profile/config data is already read live from disk on every call +(``gitea_config.load_config`` re-reads the JSON file each time), so profile +``allowed_operations`` changes take effect immediately without a restart. The +gap this module closes is *code* parity: it captures the server process's +source-tree commit at startup and detects, at mutation time, when the on-disk +``master`` HEAD has advanced past it. Detected staleness fails closed with a +restart-required recovery report, while read-only operations stay allowed so a +stale server can still be inspected. + +The core assessment is pure -- callers inject the observed HEAD SHAs -- so the +logic is fully unit-testable without a git checkout. +""" + +from __future__ import annotations + +import os +import subprocess + +# Environment escape hatches (ops + tests): +# GITEA_MCP_DISABLE_PARITY_GATE -> disable enforcement entirely (fail open). +# GITEA_TEST_CURRENT_HEAD -> force the "current" HEAD read, for tests. +ENV_DISABLE = "GITEA_MCP_DISABLE_PARITY_GATE" +ENV_TEST_CURRENT_HEAD = "GITEA_TEST_CURRENT_HEAD" + + +def read_git_head(root: str) -> str | None: + """Return the current ``HEAD`` commit SHA of *root*, or ``None``. + + ``None`` means the SHA could not be determined (not a git checkout, git + unavailable, or an error). A test override via ``GITEA_TEST_CURRENT_HEAD`` + takes precedence so the gate can be exercised deterministically. + """ + forced = os.environ.get(ENV_TEST_CURRENT_HEAD) + if forced is not None: + return forced.strip() or None + if not root: + return None + try: + res = subprocess.run( + ["git", "-C", root, "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=False, + ) + except Exception: + return None + if res.returncode != 0: + return None + return (res.stdout or "").strip() or None + + +def capture_startup_parity(root: str, head: str | None = None) -> dict: + """Capture the process source-tree baseline once at server startup. + + *head* may be injected (tests); otherwise it is read from *root*. The result + is an opaque baseline handed back to :func:`assess_master_parity`. + """ + startup_head = head if head is not None else read_git_head(root) + return {"root": root, "startup_head": startup_head} + + +def _short(sha: str | None) -> str: + return sha[:12] if sha else "unknown" + + +def assess_master_parity(startup: dict | None, current_head: str | None) -> dict: + """Compare the startup baseline against the current on-disk ``HEAD``. + + Pure: both HEADs are supplied by the caller. Returns a structured result: + + - ``in_parity`` -- server code matches the on-disk master (or parity + could not be determined, which is not treated as stale). + - ``stale`` -- the on-disk master has definitively advanced past the + running process. + - ``restart_required`` -- alias of ``stale``; the recovery action. + - ``determinable`` -- whether both HEADs were known well enough to compare. + - ``startup_head`` / ``current_head`` / ``reasons``. + """ + startup_head = (startup or {}).get("startup_head") + reasons: list[str] = [] + + if startup_head is None: + reasons.append( + "startup commit was not captured; code parity cannot be enforced") + return _result(True, False, False, startup_head, current_head, reasons) + + if current_head is None: + reasons.append( + "current workspace HEAD could not be read; code parity cannot be " + "enforced") + return _result(True, False, False, startup_head, current_head, reasons) + + if startup_head == current_head: + return _result(True, False, True, startup_head, current_head, reasons) + + reasons.append( + f"MCP server started at commit {_short(startup_head)} but the workspace " + f"master is now {_short(current_head)}; restart the server to load the " + f"current capability gates") + return _result(False, True, True, startup_head, current_head, reasons) + + +def _result(in_parity, stale, determinable, startup_head, current_head, reasons): + return { + "in_parity": in_parity, + "stale": stale, + "restart_required": stale, + "determinable": determinable, + "startup_head": startup_head, + "current_head": current_head, + "reasons": list(reasons), + } + + +def gate_disabled() -> bool: + """Whether the parity gate is disabled by env escape hatch.""" + return bool((os.environ.get(ENV_DISABLE) or "").strip()) + + +def parity_block_reasons(assessment: dict) -> list[str]: + """Block reasons for a mutation gate (empty when the mutation may proceed). + + A disabled gate or an in-parity / non-determinable assessment yields no + reasons; only a definitively stale server blocks. + """ + if gate_disabled(): + return [] + if assessment.get("stale"): + return list(assessment.get("reasons") or + ["server code is stale relative to master (fail closed)"]) + return [] + + +def parity_report(assessment: dict) -> dict: + """Structured stale-server report for permission-block payloads.""" + return { + "kind": "server_stale", + "restart_required": True, + "startup_head": assessment.get("startup_head"), + "current_head": assessment.get("current_head"), + "reasons": list(assessment.get("reasons") or []), + "recovery": [ + "The running MCP server is executing code older than the current " + "master and may not enforce newly merged capability gates.", + "Restart the Gitea MCP server so it reloads master's capability " + "gates and execution profiles before retrying the mutation.", + ], + } + + +def format_parity(assessment: dict) -> str: + """One-line human summary for logs / runtime context.""" + if assessment.get("stale"): + return (f"STALE: started {_short(assessment.get('startup_head'))}, " + f"master now {_short(assessment.get('current_head'))} " + f"(restart required)") + if not assessment.get("determinable"): + return "parity indeterminate (baseline or current HEAD unknown)" + return f"in parity at {_short(assessment.get('current_head'))}" diff --git a/mcp-menu.sh b/mcp-menu.sh index 4d124f2..215f017 100755 --- a/mcp-menu.sh +++ b/mcp-menu.sh @@ -115,7 +115,15 @@ Workflow: } show_reviewer_prompts() { - print_prompt_block "Reviewer — PR review" \ + while true; do + printf '\n--- Reviewer workflow prompts ---\n' + printf ' 1) Standard PR review\n' + printf ' 2) Skip already-reviewed stale REQUEST_CHANGES PR and hand off to author\n' + printf ' 0) Back\n' + read -r -p 'Choice: ' choice + case "$choice" in + 1) + print_prompt_block "Reviewer — PR review" \ "You are the REVIEWER session for /. Goal: review PR #

only — do not merge unless explicitly switched to merger mode. @@ -126,6 +134,35 @@ Workflow: 3. gitea_view_pr, validate scope, run required checks in the correct worktree. 4. gitea_review_pr with approve, request-changes, or comment as warranted. 5. Final report: PR head SHA, verdict, validation evidence, mutation ledger. No merge in reviewer-only runs." + ;; + 2) + print_prompt_block "Reviewer — skip already-reviewed stale REQUEST_CHANGES PR and hand off to author" \ +"You are the REVIEWER session for /. + +Goal: review PR #

only far enough to determine whether the current head already has a non-stale REQUEST_CHANGES verdict. If it does, do NOT submit another terminal review mutation — produce an author handoff and stop. + +Workflow: +1. gitea_view_pr; pin the current head SHA. +2. Load prior reviews; find the latest REQUEST_CHANGES and the head SHA it was bound to. +3. If that REQUEST_CHANGES is still bound to the current head (non-stale), do not submit another terminal review mutation. Confirm the binding and summarize the blockers. +4. Run only the diagnostics needed to produce a useful author handoff — no full re-review. +5. Duplicate/supersession check: confirm no newer canonical PR or superseding head changes the decision. +6. Stop — no new review mutation. + +Return: +- selected PR and issue +- current head SHA +- prior REQUEST_CHANGES proof (review id + bound head SHA) +- duplicate/supersession analysis +- validation summary +- why no new review mutation was submitted +- corrected mutation ledger, including local worktree create/remove if used +- author-ready fix prompt" + ;; + 0) return ;; + *) printf 'Invalid choice.\n' ;; + esac + done } show_merger_prompts() { diff --git a/pr_queue_cleanup.py b/pr_queue_cleanup.py index c1237aa..d6091ca 100644 --- a/pr_queue_cleanup.py +++ b/pr_queue_cleanup.py @@ -132,6 +132,13 @@ def check_cleanup_task_allowed(task: str) -> tuple[bool, list[str]]: _SELECTED_PR_RE = re.compile( r"^\s*[-*]?\s*selected pr\s*:\s*#?(\d+)", re.IGNORECASE | re.MULTILINE ) +_SELECTED_PR_NONE_RE = re.compile( + r"^\s*[-*]?\s*selected pr\s*:\s*(?:none|n/a)\b", re.IGNORECASE | re.MULTILINE +) +_EMPTY_QUEUE_RE = re.compile( + r"\b0 open pr|\bno open pr|\bno eligible pr|\bempty (?:review )?queue|\binventory empty|\btrusted_empty\b", + re.IGNORECASE, +) _NEXT_SUGGESTED_RE = re.compile( r"^\s*[-*]?\s*next suggested pr\s*:", re.IGNORECASE | re.MULTILINE ) @@ -176,7 +183,11 @@ def assess_pr_queue_cleanup_report(report_text: str) -> dict: selected = _SELECTED_PR_RE.findall(text) if len(selected) == 0: - reasons.append("report must name exactly one Selected PR") + if _SELECTED_PR_NONE_RE.search(text): + if not _EMPTY_QUEUE_RE.search(text): + reasons.append("Selected PR is 'none' but no valid empty-queue claim found") + else: + reasons.append("report must name exactly one Selected PR") elif len(set(selected)) > 1: reasons.append( "cleanup run selected multiple PRs " @@ -184,6 +195,9 @@ def assess_pr_queue_cleanup_report(report_text: str) -> dict: "PR per run" ) + if len(selected) > 0 and _SELECTED_PR_NONE_RE.search(text): + reasons.append("report contains both a selected PR and a claim of none selected") + terminal = _TERMINAL_MUTATION_RE.findall(text) if len(terminal) > 1: reasons.append( 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_master_parity_gate.py b/tests/test_master_parity_gate.py new file mode 100644 index 0000000..33086f0 --- /dev/null +++ b/tests/test_master_parity_gate.py @@ -0,0 +1,152 @@ +"""Tests for the master-parity staleness gate (#420). + +Covers the pure assessment logic, the mutation-block helper, the env escape +hatches, and the server-side wiring: reads are never blocked by staleness, a +mutation is refused when the on-disk master has advanced, and the read-only +gitea_assess_master_parity tool reports the stale state. +""" +import os +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) + +import master_parity_gate as mp # noqa: E402 + + +SHA_A = "a" * 40 +SHA_B = "b" * 40 + + +class TestAssessMasterParity(unittest.TestCase): + def test_in_parity_when_heads_match(self): + res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A) + self.assertTrue(res["in_parity"]) + self.assertFalse(res["stale"]) + self.assertFalse(res["restart_required"]) + self.assertTrue(res["determinable"]) + + def test_stale_when_master_advanced(self): + res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B) + self.assertFalse(res["in_parity"]) + self.assertTrue(res["stale"]) + self.assertTrue(res["restart_required"]) + self.assertTrue(res["determinable"]) + self.assertTrue(any("restart" in r for r in res["reasons"])) + + def test_missing_startup_head_not_determinable(self): + res = mp.assess_master_parity({"startup_head": None}, SHA_B) + self.assertFalse(res["determinable"]) + self.assertFalse(res["stale"]) + self.assertTrue(res["in_parity"]) + + def test_missing_current_head_not_determinable(self): + res = mp.assess_master_parity({"startup_head": SHA_A}, None) + self.assertFalse(res["determinable"]) + self.assertFalse(res["stale"]) + self.assertTrue(res["in_parity"]) + + def test_none_startup_baseline(self): + res = mp.assess_master_parity(None, SHA_A) + self.assertFalse(res["determinable"]) + self.assertFalse(res["stale"]) + + +class TestBlockReasonsAndReport(unittest.TestCase): + def test_stale_produces_block_reasons(self): + res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B) + self.assertTrue(mp.parity_block_reasons(res)) + + def test_in_parity_no_block_reasons(self): + res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A) + self.assertEqual(mp.parity_block_reasons(res), []) + + def test_disable_env_suppresses_block(self): + res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B) + with patch.dict(os.environ, {mp.ENV_DISABLE: "1"}): + self.assertTrue(mp.gate_disabled()) + self.assertEqual(mp.parity_block_reasons(res), []) + + def test_report_shape_when_stale(self): + res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B) + report = mp.parity_report(res) + self.assertEqual(report["kind"], "server_stale") + self.assertTrue(report["restart_required"]) + self.assertEqual(report["startup_head"], SHA_A) + self.assertEqual(report["current_head"], SHA_B) + self.assertTrue(report["recovery"]) + + +class TestReadGitHead(unittest.TestCase): + def test_test_override_takes_precedence(self): + with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}): + self.assertEqual(mp.read_git_head("/nonexistent"), SHA_B) + + def test_blank_override_is_none(self): + with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: " "}): + self.assertIsNone(mp.read_git_head("/nonexistent")) + + def test_empty_root_is_none(self): + # No override set; empty root cannot resolve a HEAD. + env = {k: v for k, v in os.environ.items() + if k != mp.ENV_TEST_CURRENT_HEAD} + with patch.dict(os.environ, env, clear=True): + self.assertIsNone(mp.read_git_head("")) + + +class TestServerWiring(unittest.TestCase): + """Integration with the gate choke point in the server namespace.""" + + def setUp(self): + import mcp_server + self.srv = mcp_server + # Pin a known startup baseline so the current-HEAD override can diverge. + self._saved = self.srv._STARTUP_PARITY + self.srv._STARTUP_PARITY = {"root": self.srv.PROJECT_ROOT, + "startup_head": SHA_A} + + def tearDown(self): + self.srv._STARTUP_PARITY = self._saved + + def test_reads_not_blocked_when_stale(self): + with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}): + self.assertEqual(self.srv._master_parity_block("gitea.read"), []) + + def test_mutation_blocked_when_stale(self): + with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}): + reasons = self.srv._master_parity_block("gitea.pr.create") + self.assertTrue(reasons) + # The profile-operation choke point surfaces the same block. + self.assertTrue(self.srv._profile_operation_gate("gitea.pr.create")) + + def test_mutation_allowed_when_in_parity(self): + with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A}): + self.assertEqual( + self.srv._master_parity_block("gitea.pr.create"), []) + + def test_disable_env_lets_mutation_through(self): + with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B, + mp.ENV_DISABLE: "1"}): + self.assertEqual( + self.srv._master_parity_block("gitea.pr.create"), []) + + def test_assess_tool_reports_stale(self): + with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}): + out = self.srv.gitea_assess_master_parity(remote="prgs") + self.assertTrue(out["stale"]) + self.assertTrue(out["restart_required"]) + self.assertEqual(out["startup_head"], SHA_A) + self.assertEqual(out["current_head"], SHA_B) + self.assertIn("report", out) + + def test_assess_tool_reports_in_parity(self): + with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A}): + out = self.srv.gitea_assess_master_parity(remote="prgs") + self.assertFalse(out["stale"]) + self.assertTrue(out["in_parity"]) + self.assertNotIn("report", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mcp_menu_script.py b/tests/test_mcp_menu_script.py index a447648..b52ecad 100644 --- a/tests/test_mcp_menu_script.py +++ b/tests/test_mcp_menu_script.py @@ -105,6 +105,19 @@ class TestMcpMenuScript(unittest.TestCase): self.assertIn("./mcp-menu.sh", docs_text) self.assertIn("placeholder", docs_text.lower()) + def test_reviewer_skip_stale_request_changes_prompt_discoverable(self): + # #482: the skip-already-reviewed-stale-REQUEST_CHANGES reviewer prompt + # must be reachable from the reviewer menu and documented. + label = "Skip already-reviewed stale REQUEST_CHANGES PR and hand off to author" + reviewer_fn = self._extract_function("show_reviewer_prompts") + self.assertIn(label, reviewer_fn, "new reviewer prompt label must appear in the reviewer menu") + # The prompt must instruct: no duplicate terminal review mutation for a + # non-stale REQUEST_CHANGES head, and must carry the author handoff. + self.assertIn("do NOT submit another terminal review mutation", reviewer_fn) + self.assertIn("author-ready fix prompt", reviewer_fn) + docs_text = DOCS.read_text(encoding="utf-8") + self.assertIn("REQUEST_CHANGES", docs_text) + def _extract_function(self, name: str) -> str: marker = f"{name}() {{" start = self.content.index(marker) diff --git a/tests/test_pr_queue_cleanup.py b/tests/test_pr_queue_cleanup.py index e26a026..caff6cc 100644 --- a/tests/test_pr_queue_cleanup.py +++ b/tests/test_pr_queue_cleanup.py @@ -265,5 +265,38 @@ class TestCleanupReportVerifier(unittest.TestCase): self.assertEqual(direct["proven"], wrapped["proven"]) +class TestCleanupEmptyQueue(unittest.TestCase): + def test_empty_queue_report_passes(self): + report = _clean_report( + selected="Selected PR: none", + decision="Review decision: comment", + next="Next suggested PR: approvals (queue empty)", + pagination="PR inventory pagination proof: inventory_complete=true, total_count 0", + ) + report += "\npr_inventory_trust_gate.status: trusted_empty" + result = assess_pr_queue_cleanup_report(report) + self.assertTrue(result["proven"], result["reasons"]) + + def test_empty_queue_without_evidence_fails(self): + report = _clean_report( + selected="Selected PR: none", + ) + result = assess_pr_queue_cleanup_report(report) + self.assertFalse(result["proven"]) + self.assertTrue( + any("no valid empty-queue claim" in r for r in result["reasons"]), + result["reasons"] + ) + + def test_contradictory_selected_pr_fails(self): + report = _clean_report() + "\nSelected PR: none" + result = assess_pr_queue_cleanup_report(report) + self.assertFalse(result["proven"]) + self.assertTrue( + any("contains both a selected PR and a claim of none selected" in r for r in result["reasons"]), + result["reasons"] + ) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_reconciler_cleanup_integration.py b/tests/test_reconciler_cleanup_integration.py new file mode 100644 index 0000000..7637602 --- /dev/null +++ b/tests/test_reconciler_cleanup_integration.py @@ -0,0 +1,263 @@ +"""Integration tests for reconciler merged cleanup (#523).""" +import os +import sys +import unittest +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", + "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": [], +} + +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): + 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 = [] + 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" + ).start() + + def tearDown(self): + patch.stopall() + 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", + ) + + self.mock_api.side_effect = [ + [ # closed pulls page + { + "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"}, + }, + { + # 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, + ): + result = mcp_server.gitea_reconcile_merged_cleanups( + dry_run=True, + remote="prgs", + ) + self.assertTrue(result["success"]) + self.assertTrue(result["dry_run"]) + 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_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", + ) + + 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__": + unittest.main() 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 c66158c..0fdf1db 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 efe0125..99e3786 100644 --- a/webui/app.py +++ b/webui/app.py @@ -18,8 +18,10 @@ from webui.gated_actions import attempt_action, load_action_registry, preview_ac from webui.gated_action_views import render_actions_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"}) @@ -66,7 +68,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: @@ -125,10 +127,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: @@ -214,6 +218,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/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 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