Merge remote-tracking branch 'prgs/master' into feat/issue-514-branch-delete-guard

# Conflicts:
#	gitea_mcp_server.py
This commit is contained in:
2026-07-09 08:28:50 -04:00
19 changed files with 1326 additions and 17 deletions
+21
View File
@@ -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
+1 -1
View File
@@ -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. |
+12 -2
View File
@@ -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
```
+89 -1
View File
@@ -829,6 +829,13 @@ import review_merge_state_machine # noqa: E402
import pr_work_lease # noqa: E402
import native_mcp_preference # noqa: E402
import branch_cleanup_guard # 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
@@ -5729,15 +5736,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:
@@ -7841,6 +7874,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("/")
@@ -7848,6 +7899,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,
+168
View File
@@ -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'))}"
+38 -1
View File
@@ -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 <org>/<repo>.
Goal: review PR #<P> 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 <org>/<repo>.
Goal: review PR #<P> 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() {
+15 -1
View File
@@ -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(
+2 -2
View File
@@ -110,11 +110,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",
+1 -1
View File
@@ -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__":
+152
View File
@@ -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()
+13
View File
@@ -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)
+33
View File
@@ -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()
@@ -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()
+88
View File
@@ -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()
+6 -1
View File
@@ -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)
+11 -6
View File
@@ -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"]),
+1 -1
View File
@@ -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
+320
View File
@@ -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,
}
+92
View File
@@ -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 "<p class='muted'>No hashes available.</p>"
rows = []
for item in items:
digest = item.sha256 or item.error or "unavailable"
rows.append(
"<tr>"
f"<td>{html.escape(item.label)}</td>"
f"<td><code>{html.escape(item.path)}</code></td>"
f"<td><code>{html.escape(digest[:16] if item.sha256 else digest)}</code></td>"
"</tr>"
)
return (
"<table class='registry'><thead><tr>"
"<th>Artifact</th><th>Path</th><th>SHA-256</th>"
"</tr></thead><tbody>"
f"{''.join(rows)}</tbody></table>"
)
def render_runtime_page(snapshot: RuntimeSnapshot) -> str:
error_block = ""
if snapshot.fetch_error:
error_block = (
'<div class="stub"><p><strong>Runtime snapshot incomplete:</strong> '
f"{html.escape(snapshot.fetch_error)}</p></div>"
)
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 = (
'<div class="stub"><p><strong>Stale runtime warning:</strong> '
f"{html.escape(snapshot.stale_runtime_warning)}</p></div>"
)
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 (
"<h2>Runtime health</h2>"
f"<p class='meta'>Project <code>{html.escape(snapshot.project_id)}</code> · "
f"host <code>{html.escape(snapshot.host)}</code> · "
f"repo root <code>{html.escape(snapshot.repo_root)}</code></p>"
f"{error_block}"
f"{stale_block}"
"<h3>Active profile</h3>"
"<table class='detail'>"
f"<tr><th>Profile</th><td><code>{html.escape(snapshot.profile_name)}</code></td></tr>"
f"<tr><th>Role kind</th><td>{html.escape(snapshot.role_kind)}</td></tr>"
f"<tr><th>Identity</th><td>{html.escape(identity)}</td></tr>"
f"<tr><th>Config model</th><td>{html.escape(snapshot.config_model)}</td></tr>"
f"<tr><th>Profile mode</th><td>{html.escape(snapshot.profile_mode)} "
f"({html.escape(snapshot.profile_source)})</td></tr>"
"</table>"
"<h3>Server code sync</h3>"
"<table class='detail'>"
f"<tr><th>Local HEAD</th><td><code>{html.escape(snapshot.repo_sha or 'unknown')}</code></td></tr>"
f"<tr><th>Remote {html.escape(snapshot.remote)}/{html.escape('master')}</th>"
f"<td><code>{html.escape(snapshot.remote_master_sha or 'unknown')}</code></td></tr>"
f"<tr><th>Commits behind</th><td>{snapshot.commits_behind_master if snapshot.commits_behind_master is not None else 'unknown'}</td></tr>"
"</table>"
"<h3>Shell health</h3>"
f"<p class='meta'>{html.escape(shell_summary)}</p>"
f"<p class='muted'>{html.escape(str(shell.get('safe_next_action') or ''))}</p>"
"<h3>Workflow hashes</h3>"
f"{_hash_rows(snapshot.workflow_hashes)}"
"<h3>Schema hashes</h3>"
f"{_hash_rows(snapshot.schema_hashes)}"
"<h3>Restart / reload</h3>"
"<p class='muted'>MVP is read-only — restart MCP servers from your IDE/operator "
"workflow. Related issue: <code>#420</code>. Guidance: "
f"<code>{html.escape(snapshot.restart_guidance)}</code></p>"
"<p class='muted'>This page does not expose tokens or perform MCP restarts.</p>"
)