336 lines
11 KiB
Python
336 lines
11 KiB
Python
"""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)
|
|
offline_test = (os.environ.get("WEBUI_TEST_OFFLINE") or "").strip() in {
|
|
"1",
|
|
"true",
|
|
"yes",
|
|
}
|
|
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
|
|
if identity_fn is None:
|
|
if offline_test:
|
|
identity_fn = lambda _host: (None, "offline web UI test mode")
|
|
else:
|
|
identity_fn = _resolve_username
|
|
username, identity_error = identity_fn(host)
|
|
|
|
git_fn = git_sync
|
|
if git_fn is None:
|
|
if offline_test:
|
|
git_fn = lambda _repo, _remote, _branch: (None, None, None)
|
|
else:
|
|
git_fn = _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,
|
|
}
|