Files
Gitea-Tools/webui/app.py
T
jcwalker3andClaude Opus 4.8 5494696227 feat(webui): read-only system-health API (Closes #634)
Adds `GET /api/v1/system/health`, a structured read-only health surface for
automated readiness checks, and keeps `/health` as the cheap liveness probe.

webui/system_health.py composes a DTO from fail-soft dependency probes: the
control-plane database, the local checkout, and — opt-in via `?deep=1` — live
Gitea reachability, each carrying status, reason, and probe latency. Required
probes drive readiness; the optional Gitea probe can only degrade overall
status, because local inventory stays serveable when the remote is
unreachable. A probe that did not run leaves readiness incomplete rather than
silently passing.

Read-only throughout: the control-plane database is opened through a `mode=ro`
URI because `ControlPlaneDB.__init__` creates directories and runs migrations,
which a health check must never do. No restart or reload control is exposed;
those are Phase 2 and #630 forbids process-kill recovery.

No unproven claims: `stale_runtime.mutation_safe` is true only when the
runtime, checkout, and remote commits are all known and equal, and MCP
namespaces always report `unproven` because a web process cannot exercise the
IDE-managed client path (#543). Probe details are redacted at the browser
boundary — URLs lose userinfo and query strings, credential-shaped text is
masked.

`/health` is expanded additively: every MVP key is retained, plus `started_at`,
`uptime_seconds`, and a pointer to the versioned API. The versioned route
returns 503 when not ready so automation can branch on the status code alone.

Verified at master 9eb0f29: focused file 40 passed / 11 subtests; `-k "webui or
health"` 230 passed / 159 subtests; full suite 4358 passed with the 11
pre-existing master-drift failures unchanged from the clean-master baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-22 15:59:30 -05:00

332 lines
13 KiB
Python

"""Starlette application for the internal read-only web UI (#426)."""
from __future__ import annotations
from datetime import datetime, timezone
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse, Response
from starlette.routing import Route
from webui.deployment_boundary import deployment_snapshot
from webui.layout import render_page
from webui.project_registry import find_project, load_registry, registry_to_dict
from webui.project_views import render_project_detail, render_projects_list
from webui.prompt_library import find_prompt, library_to_dict
from webui.prompt_views import render_prompt_detail, render_prompts_page
from final_report_validator import FINAL_REPORT_TASK_KINDS
from webui.gated_actions import attempt_action, load_action_registry, preview_action
from webui.gated_action_views import render_actions_page
from webui.audit_validator import audit_report, audit_to_dict
from webui.audit_views import render_audit_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 as queue_snapshot_to_dict
from webui.queue_views import render_queue_page
from webui.worktree_scanner import load_hygiene_snapshot, snapshot_to_dict as worktree_snapshot_to_dict
from webui.worktree_views import render_worktrees_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
from webui.system_health import (
API_PATH as SYSTEM_HEALTH_API_PATH,
load_system_health,
process_uptime,
snapshot_to_dict as system_health_to_dict,
)
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
_AUDIT_MUTATION_PATHS = frozenset({"/audit", "/api/audit"})
def _stub_page(title: str, description: str) -> HTMLResponse:
body = (
f"<h2>{title}</h2>"
f'<div class="stub"><p>{description}</p>'
"<p>Implementation tracked in a child issue of #425.</p></div>"
)
return HTMLResponse(render_page(title=title, body_html=body))
async def home(_request: Request) -> HTMLResponse:
body = (
"<h2>Operator console</h2>"
"<p>Local entry point for MCP Control Plane operational views.</p>"
"<ul>"
"<li><strong>Queue</strong> — live PR and issue dashboard (#429)</li>"
"<li><strong>Projects</strong> — registry and onboarding (#427)</li>"
"<li><strong>Prompts</strong> — canonical workflow prompt library (#428)</li>"
"<li><strong>Runtime</strong> — MCP health and stale-runtime detection (#430)</li>"
"<li><strong>Audit</strong> — final-report paste and validator preview (#431)</li>"
"<li><strong>Worktrees</strong> — branch hygiene dashboard (#432)</li>"
"<li><strong>Leases</strong> — collision and lease visibility (#433)</li>"
"<li><strong>Actions</strong> — gated write-action framework (#434)</li>"
"</ul>"
)
return HTMLResponse(render_page(title="Home", body_html=body))
async def health(_request: Request) -> JSONResponse:
"""Liveness only — deliberately cheap, runs no dependency probe (#634).
Every MVP key is retained so existing pollers keep working; the additions
are a pointer to the structured API and the in-memory process uptime.
Readiness lives at that API because answering it costs real probes.
"""
bind_host = _request.app.state.webui_bind_host
started_at, uptime_seconds = process_uptime()
return JSONResponse({
"status": "ok",
"service": "mcp-control-plane-webui",
"mode": "read-only-mvp",
"timestamp": datetime.now(timezone.utc).isoformat(),
"deployment": deployment_snapshot(bind_host=bind_host),
"started_at": started_at,
"uptime_seconds": uptime_seconds,
"system_health_api": SYSTEM_HEALTH_API_PATH,
})
def _truthy_flag(value: str | None) -> bool:
return (value or "").strip().lower() in {"1", "true", "yes", "on"}
async def api_system_health(request: Request) -> JSONResponse:
"""Structured read-only system health (#634).
`?deep=1` opts into the expensive network probe. The response status code
reflects readiness so automated checks can branch on it without parsing the
body: 200 when ready, 503 when a required dependency failed or never ran.
"""
deep = _truthy_flag(request.query_params.get("deep"))
snapshot = load_system_health(deep=deep)
payload = system_health_to_dict(snapshot)
return JSONResponse(payload, status_code=200 if snapshot.ready else 503)
async def queue(_request: Request) -> HTMLResponse:
snapshot = load_queue_snapshot()
return HTMLResponse(render_page(title="Queue", body_html=render_queue_page(snapshot)))
async def api_queue(_request: Request) -> JSONResponse:
return JSONResponse(queue_snapshot_to_dict(load_queue_snapshot()))
async def projects(_request: Request) -> HTMLResponse:
registry = load_registry()
return HTMLResponse(render_projects_list(registry))
async def project_detail(request: Request) -> HTMLResponse:
project_id = request.path_params["project_id"]
registry = load_registry()
project = find_project(registry, project_id)
if project is None:
return HTMLResponse(
render_page(
title="Project not found",
body_html=(
"<h2>Project not found</h2>"
f"<p>No registry entry for <code>{project_id}</code>.</p>"
'<p><a href="/projects">← All projects</a></p>'
),
),
status_code=404,
)
return HTMLResponse(render_project_detail(project))
async def api_projects(_request: Request) -> JSONResponse:
registry = load_registry()
return JSONResponse(registry_to_dict(registry))
async def prompts(_request: Request) -> HTMLResponse:
return HTMLResponse(render_prompts_page())
async def prompt_detail(request: Request) -> HTMLResponse:
prompt_id = request.path_params["prompt_id"]
prompt = find_prompt(prompt_id)
if prompt is None:
return HTMLResponse(
render_page(
title="Prompt not found",
body_html=(
"<h2>Prompt not found</h2>"
f"<p>No library entry for <code>{prompt_id}</code>.</p>"
'<p><a href="/prompts">← All prompts</a></p>'
),
),
status_code=404,
)
return HTMLResponse(render_prompt_detail(prompt))
async def api_prompts(_request: Request) -> JSONResponse:
return JSONResponse(library_to_dict())
async def runtime(_request: Request) -> HTMLResponse:
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 _parse_audit_form(request: Request) -> tuple[str, str | None]:
if request.method == "GET":
return "", None
content_type = (request.headers.get("content-type") or "").lower()
if "application/json" in content_type:
payload = await request.json()
if not isinstance(payload, dict):
return "", None
report_text = str(payload.get("report_text") or "")
task_kind = payload.get("task_kind")
return report_text, str(task_kind) if task_kind else None
form = await request.form()
report_text = str(form.get("report_text") or "")
task_kind = form.get("task_kind")
return report_text, str(task_kind) if task_kind else None
async def audit(request: Request) -> HTMLResponse:
report_text, task_kind = await _parse_audit_form(request)
result = None
if request.method == "POST" and report_text.strip():
result = audit_report(report_text, task_kind=task_kind)
return HTMLResponse(
render_audit_page(
report_text=report_text,
task_kind=task_kind,
result=result,
)
)
async def api_audit(request: Request) -> JSONResponse:
if request.method == "GET":
return JSONResponse({
"usage": "POST JSON with report_text and optional task_kind",
"task_kinds": sorted(FINAL_REPORT_TASK_KINDS),
})
report_text, task_kind = await _parse_audit_form(request)
if not report_text.strip():
return JSONResponse({"error": "report_text required"}, status_code=400)
return JSONResponse(audit_to_dict(audit_report(report_text, task_kind=task_kind)))
async def worktrees(_request: Request) -> HTMLResponse:
snapshot = load_hygiene_snapshot()
return HTMLResponse(render_worktrees_page(snapshot))
async def api_worktrees(_request: Request) -> JSONResponse:
return JSONResponse(worktree_snapshot_to_dict(load_hygiene_snapshot()))
async def leases(_request: Request) -> HTMLResponse:
snapshot = load_lease_snapshot()
return HTMLResponse(render_leases_page(snapshot))
async def api_leases(_request: Request) -> JSONResponse:
return JSONResponse(lease_snapshot_to_dict(load_lease_snapshot()))
async def actions(_request: Request) -> HTMLResponse:
registry = load_action_registry()
return HTMLResponse(render_actions_page(registry))
async def api_actions(_request: Request) -> JSONResponse:
return JSONResponse(load_action_registry().to_dict())
async def api_action_preview(request: Request) -> JSONResponse:
action_id = request.path_params["action_id"]
params = dict(request.query_params)
for key in ("issue_number", "pr_number"):
if key in params:
params[key] = int(params[key])
result = preview_action(action_id, **params)
if "error" in result:
return JSONResponse(result, status_code=404)
return JSONResponse(result)
async def api_action_attempt(request: Request) -> JSONResponse:
"""POST is rejected by read-only guard; kept for explicit fail-closed tests."""
action_id = request.path_params["action_id"]
try:
body = await request.json()
except Exception:
body = {}
if not isinstance(body, dict):
body = {}
result = attempt_action(action_id, **body)
status = 403 if not result.get("success") else 200
return JSONResponse(result, status_code=status)
async def method_not_allowed(request: Request, _exc: Exception) -> Response:
path = request.url.path
if path in _AUDIT_MUTATION_PATHS and request.method == "POST":
return JSONResponse({"error": "not_found"}, status_code=404)
if request.method not in _READ_ONLY_METHODS:
return JSONResponse(
{"error": "read-only-mvp", "detail": f"{request.method} not permitted"},
status_code=405,
)
return JSONResponse({"error": "not_found"}, status_code=404)
def create_app(*, bind_host: str | None = None) -> Starlette:
"""Build the read-only MVP Starlette app."""
import os
resolved_bind = bind_host or os.environ.get("WEBUI_HOST", "127.0.0.1")
app = Starlette(
debug=False,
routes=[
Route("/", home, methods=["GET"]),
Route("/health", health, methods=["GET"]),
Route(SYSTEM_HEALTH_API_PATH, api_system_health, methods=["GET"]),
Route("/queue", queue, methods=["GET"]),
Route("/api/queue", api_queue, methods=["GET"]),
Route("/projects", projects, methods=["GET"]),
Route("/projects/{project_id}", project_detail, methods=["GET"]),
Route("/api/projects", api_projects, methods=["GET"]),
Route("/prompts", prompts, methods=["GET"]),
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", "POST"]),
Route("/api/audit", api_audit, methods=["GET", "POST"]),
Route("/worktrees", worktrees, methods=["GET"]),
Route("/api/worktrees", api_worktrees, methods=["GET"]),
Route("/leases", leases, methods=["GET"]),
Route("/actions", actions, methods=["GET"]),
Route("/api/actions", api_actions, methods=["GET"]),
Route(
"/api/actions/{action_id}/preview",
api_action_preview,
methods=["GET"],
),
Route(
"/api/actions/{action_id}/attempt",
api_action_attempt,
methods=["POST"],
),
Route("/api/leases", api_leases, methods=["GET"]),
],
exception_handlers={405: method_not_allowed},
)
app.state.webui_bind_host = resolved_bind
return app