Compare commits

..
Author SHA1 Message Date
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
11 changed files with 1296 additions and 1488 deletions
-1
View File
@@ -87,7 +87,6 @@ MUTATION_TASKS = frozenset({
"edit_pr",
"commit_files",
"gitea_commit_files",
"publish_unpublished_branch",
"delete_branch",
"cleanup_merged_pr_branch",
"cleanup_stale_claims",
-591
View File
@@ -1,591 +0,0 @@
"""Publish an unpublished local commit on a registered issue worktree (#812 AC20).
Entry point B of #812 is the state where an author's work has already advanced
to a local commit: the worktree is registered, clean, on the issue branch, and
carries the only copy of the implementation, but the branch has never been
published. That state deadlocks, because two individually correct predicates
close a cycle:
* ``issue_lock_renewal.assess_exact_owner_lease_renewal`` refuses to renew an
expired lease without an observable remote head — an unpublished branch has
none.
* Every publication path (``gitea_commit_files``, ``gitea_create_pr``) derives
its workspace from the author issue lock under #618, so nothing can create
that remote head without first holding the lock.
This module supplies the missing operation: it publishes an *already committed*
local head to the remote branch, so exact-owner renewal has the evidence it
requires. It deliberately does **not** renew, reclaim, rebind, or clear any
lock. Publication is the whole of its authority.
Why this is not a lock bypass
-----------------------------
The operation can only publish a branch whose **durable issue-lock record
already names the caller as claimant**. Ownership is read from the lock file on
disk (``issue_lock_store``), never from a caller-supplied flag, so the tool
cannot manufacture a claim it does not already hold. Nothing here weakens the
#510/#618/#713 guards: a dirty tree, an unregistered worktree, a foreign
claimant, a changed HEAD, or a divergent remote head each refuse, exactly as
they do today. The only thing this adds is the ability to make an existing,
owned, committed, clean branch observable on the remote.
Separation of records (#812 AC23)
---------------------------------
The durable **issue-lock file** and the control-plane **workflow lease** are
distinct records. This module reads the former as ownership evidence and writes
neither. Publishing changes remote git state only; no lock is renewed,
abandoned, reclaimed, or generation-bumped here.
Process evidence (#812 AC24)
----------------------------
Liveness of the lock's recorded pid is **not consulted**. That is deliberate:
the recorded pid routinely belongs to the long-running MCP daemon rather than to
an active author client, and the existing reclaim predicate
(``assess_expired_lock_reclaim``) can never be satisfied while that daemon runs.
Publication does not require the recording process to be dead, so this module
never asserts, infers, or depends on a process being dead. Ownership is proven
by identity and profile match against the recorded claimant instead.
"""
from __future__ import annotations
import hashlib
import os
import re
import subprocess
from reviewer_worktree import parse_dirty_tracked_files
from stable_branch_push_guard import is_stable_ref, redact_command
# Assessment outcomes.
PUBLISH_SANCTIONED = "publish_sanctioned"
ALREADY_PUBLISHED = "already_published"
REFUSED = "refused"
#: Implementation branches must stay traceable to their issue (#713 lineage).
ISSUE_BRANCH_RE = re.compile(r"^(fix|feat|docs|chore)/issue-(\d+)-.+$")
_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
def _text(value: object) -> str:
return value.strip() if isinstance(value, str) else ""
def _realpath(value: str | None) -> str | None:
path = _text(value)
return os.path.realpath(path) if path else None
def parse_untracked_files(porcelain_status: str) -> list[str]:
"""Return untracked paths from ``git status --porcelain`` output.
``reviewer_worktree.parse_dirty_tracked_files`` deliberately skips ``??``
entries. Publication needs both halves: an untracked file in the worktree is
unpublished content that the commit does not carry, so publishing would
silently leave it behind.
"""
untracked: list[str] = []
for line in (porcelain_status or "").splitlines():
if not line.startswith("??"):
continue
path = line[2:].strip()
if path:
untracked.append(path)
return untracked
def hash_worktree_files(worktree_path: str, paths) -> dict[str, str | None]:
"""SHA-256 each path under *worktree_path*; ``None`` when unreadable."""
root = _text(worktree_path)
hashes: dict[str, str | None] = {}
for rel in paths or ():
rel_text = _text(rel)
if not rel_text:
continue
full = os.path.join(root, rel_text)
try:
with open(full, "rb") as handle:
digest = hashlib.sha256()
for chunk in iter(lambda: handle.read(65536), b""):
digest.update(chunk)
hashes[rel_text] = digest.hexdigest()
except OSError:
hashes[rel_text] = None
return hashes
def read_remote_branch_head(
worktree_path: str, remote_name: str, branch_name: str
) -> dict:
"""Observe the remote head for *branch_name*, read-only.
``probe_ok`` False means git could not answer at all. That is kept distinct
from "the branch does not exist": an unobservable remote must fail closed
rather than be mistaken for an absent branch, because the two lead to
opposite dispositions.
"""
path = _text(worktree_path)
remote = _text(remote_name)
branch = _text(branch_name)
result: dict = {
"probe_ok": False,
"remote_branch_exists": False,
"remote_head_sha": None,
"reasons": [],
}
if not (path and remote and branch):
result["reasons"].append(
"remote head probe requires a worktree path, remote name, and branch"
)
return result
try:
res = subprocess.run(
["git", "-C", path, "ls-remote", remote, f"refs/heads/{branch}"],
capture_output=True,
text=True,
check=False,
)
except OSError as exc: # git unavailable — fail closed, never assume absent
result["reasons"].append(f"remote head probe could not run: {exc}")
return result
if res.returncode != 0:
result["reasons"].append(
f"remote head probe failed for '{branch}' on remote '{remote}'"
)
return result
result["probe_ok"] = True
for line in (res.stdout or "").splitlines():
parts = line.split()
if len(parts) >= 2 and parts[1] == f"refs/heads/{branch}":
result["remote_branch_exists"] = True
result["remote_head_sha"] = parts[0].strip()
break
return result
def read_is_ancestor(
worktree_path: str, ancestor_sha: str, descendant_sha: str
) -> dict:
"""Observe whether *ancestor_sha* is an ancestor of *descendant_sha*."""
path = _text(worktree_path)
ancestor = _text(ancestor_sha)
descendant = _text(descendant_sha)
result: dict = {"probe_ok": False, "is_ancestor": False, "reasons": []}
if not (path and ancestor and descendant):
result["reasons"].append(
"ancestry probe requires a worktree path and both commit SHAs"
)
return result
try:
present = subprocess.run(
["git", "-C", path, "rev-parse", "--verify", "--quiet",
f"{ancestor}^{{commit}}"],
capture_output=True, text=True, check=False,
)
if present.returncode != 0:
result["reasons"].append(
f"remote head {ancestor} is not present locally, so it cannot be "
"proven an ancestor of the commit being published"
)
return result
res = subprocess.run(
["git", "-C", path, "merge-base", "--is-ancestor", ancestor, descendant],
capture_output=True, text=True, check=False,
)
except OSError as exc:
result["reasons"].append(f"ancestry probe could not run: {exc}")
return result
result["probe_ok"] = res.returncode in (0, 1)
result["is_ancestor"] = res.returncode == 0
return result
def assess_unpublished_commit_publication(
existing_lock,
*,
issue_number: int,
branch_name: str,
worktree_path: str,
expected_head: str,
remote: str,
org: str,
repo: str,
identity: str | None,
profile: str | None,
worktree_state,
worktree_registered: bool | None = None,
remote_probe=None,
ancestry=None,
competing_open_prs=(),
expected_file_hashes=None,
observed_file_hashes=None,
) -> dict:
"""Decide whether an unpublished local commit may be published (#812 AC20).
Pure predicate. Every input is either a caller-declared expectation that
must be *matched* against observation, or a server-side observation. No
caller-supplied boolean is accepted as proof of ownership, liveness, or
eligibility: ``existing_lock`` comes from the durable lock file and the
git/PR state is observed by the server.
The single mutating disposition it can return is "publish this exact commit
to this exact branch". It never sanctions renewal, reclamation, force
updates, history rewriting, or publication of uncommitted content.
"""
reasons: list[str] = []
branch = _text(branch_name)
head = _text(expected_head).lower()
workspace = _realpath(worktree_path)
state = worktree_state if isinstance(worktree_state, dict) else {}
lock = existing_lock if isinstance(existing_lock, dict) else None
evidence: dict = {
"issue_number": issue_number,
"branch_name": branch or None,
"worktree_path": workspace,
"expected_head": head or None,
"remote": _text(remote) or None,
"org": _text(org) or None,
"repo": _text(repo) or None,
"identity": _text(identity) or None,
"profile": _text(profile) or None,
"lock_record_present": lock is not None,
"recorded_claimant": None,
"recorded_branch": None,
"recorded_worktree": None,
"lock_generation": None,
"local_head_sha": _text(state.get("head_sha")) or None,
"current_branch": _text(state.get("current_branch")) or None,
"dirty_tracked_files": [],
"untracked_files": [],
"worktree_registered": worktree_registered,
"remote_branch_exists": None,
"remote_head_sha": None,
"fast_forward_from_remote": None,
"competing_open_prs": [],
"file_hashes_verified": None,
"hash_mismatches": [],
# Recorded explicitly so no reader mistakes silence for a liveness
# claim, and so the audit shows which records were left alone (AC23/AC24).
"owner_pid_liveness_consulted": False,
"workflow_lease_touched": False,
"issue_lock_record_mutated": False,
}
# ── declared shape ────────────────────────────────────────────────────
if not branch:
reasons.append("branch name not declared; fail closed")
if not head:
reasons.append(
"expected_head not declared; publication must name the exact commit"
)
elif not _SHA_RE.match(head):
reasons.append(
f"expected_head '{head}' is not a full 40-character commit SHA; "
"abbreviated or symbolic revisions are refused"
)
if not workspace:
reasons.append("worktree path not declared; fail closed")
if branch:
match = ISSUE_BRANCH_RE.match(branch)
if not match:
reasons.append(
f"branch '{branch}' is not an issue-linked implementation branch "
"((fix|feat|docs|chore)/issue-<number>-<description>); fail closed"
)
elif int(match.group(2)) != int(issue_number):
reasons.append(
f"branch '{branch}' does not carry issue number {issue_number}; "
"fail closed"
)
if is_stable_ref(branch):
reasons.append(
f"refusing to publish stable branch '{branch}'; this operation "
"publishes issue branches only"
)
# ── ownership: durable issue-lock record only (AC8, AC20, AC24) ───────
if lock is None:
reasons.append(
"no durable issue-lock record for this issue; publication requires an "
"existing recorded claim naming the caller, so this operation cannot "
"be used to bypass the author lock"
)
else:
lease = lock.get("work_lease")
lease = lease if isinstance(lease, dict) else {}
claimant = lease.get("claimant")
claimant = claimant if isinstance(claimant, dict) else {}
recorded_user = _text(claimant.get("username"))
recorded_profile = _text(claimant.get("profile"))
recorded_branch = _text(lock.get("branch_name")) or _text(lease.get("branch"))
recorded_worktree = _realpath(
_text(lock.get("worktree_path")) or _text(lease.get("worktree_path"))
)
evidence["recorded_claimant"] = {
"username": recorded_user or None,
"profile": recorded_profile or None,
}
evidence["recorded_branch"] = recorded_branch or None
evidence["recorded_worktree"] = recorded_worktree
try:
evidence["lock_generation"] = int(lock.get("lock_generation") or 0)
except (TypeError, ValueError):
evidence["lock_generation"] = 0
try:
recorded_issue = int(lock.get("issue_number") or 0)
except (TypeError, ValueError):
recorded_issue = 0
if recorded_issue != int(issue_number):
reasons.append(
f"durable lock records issue {lock.get('issue_number')}, not "
f"{issue_number}; ambiguous ownership, fail closed"
)
for field, declared in (
("remote", _text(remote)),
("org", _text(org)),
("repo", _text(repo)),
):
recorded = _text(lock.get(field))
if recorded and declared and recorded != declared:
reasons.append(
f"durable lock records {field} '{recorded}' but the request "
f"declares '{declared}'; repository mismatch, fail closed"
)
if recorded_branch and branch and recorded_branch != branch:
reasons.append(
f"durable lock records branch '{recorded_branch}' but the request "
f"declares '{branch}'; fail closed"
)
if recorded_worktree and workspace and recorded_worktree != workspace:
reasons.append(
f"durable lock records worktree '{recorded_worktree}' but the "
f"request declares '{workspace}'; fail closed"
)
if not recorded_user or not recorded_profile:
reasons.append(
"durable lock does not record a claimant username and profile; "
"ownership cannot be proven, fail closed"
)
else:
if recorded_user != _text(identity):
reasons.append(
f"durable lock claimant '{recorded_user}' is not the acting "
f"identity '{_text(identity) or '(unknown)'}'; foreign claim, "
"fail closed"
)
if recorded_profile != _text(profile):
reasons.append(
f"durable lock claimant profile '{recorded_profile}' is not "
f"the active profile '{_text(profile) or '(unknown)'}'; "
"fail closed"
)
# ── worktree: registered, on-branch, clean, at the expected commit ────
if worktree_registered is False:
reasons.append(
f"worktree '{workspace}' is not listed in git worktree list; #713 "
"requires a genuinely registered worktree, fail closed"
)
current_branch = _text(state.get("current_branch"))
if not current_branch:
reasons.append("worktree branch could not be observed; fail closed")
elif branch and current_branch != branch:
reasons.append(
f"worktree is on branch '{current_branch}', not '{branch}'; fail closed"
)
porcelain = state.get("porcelain_status") or ""
dirty_tracked = parse_dirty_tracked_files(porcelain)
untracked = parse_untracked_files(porcelain)
evidence["dirty_tracked_files"] = dirty_tracked
evidence["untracked_files"] = untracked
if dirty_tracked:
reasons.append(
"worktree has dirty tracked files, so the commit is not the whole of "
f"the work: {', '.join(dirty_tracked)}. This operation publishes an "
"existing clean commit only; uncommitted content is out of scope"
)
if untracked:
reasons.append(
"worktree has untracked files that the commit does not carry: "
f"{', '.join(untracked)}. Publishing would silently leave them "
"behind; fail closed"
)
local_head = _text(state.get("head_sha")).lower()
if not local_head:
reasons.append("local HEAD could not be observed; fail closed")
elif head and local_head != head:
reasons.append(
f"worktree HEAD is {local_head} but the request declares {head}; the "
"local commit changed since it was recorded, fail closed"
)
# ── remote state ──────────────────────────────────────────────────────
probe = remote_probe if isinstance(remote_probe, dict) else {}
already_published = False
if not probe.get("probe_ok"):
reasons.append(
"remote branch head could not be observed; publication must not "
"proceed against an unknown remote state, fail closed"
)
reasons.extend(probe.get("reasons") or [])
else:
remote_exists = bool(probe.get("remote_branch_exists"))
remote_head = _text(probe.get("remote_head_sha")).lower() or None
evidence["remote_branch_exists"] = remote_exists
evidence["remote_head_sha"] = remote_head
if remote_exists and remote_head and head:
if remote_head == head:
already_published = True
evidence["fast_forward_from_remote"] = True
else:
anc = ancestry if isinstance(ancestry, dict) else {}
is_anc = bool(anc.get("probe_ok")) and bool(anc.get("is_ancestor"))
evidence["fast_forward_from_remote"] = is_anc
if not is_anc:
reasons.append(
f"remote branch '{branch}' already exists at {remote_head}, "
f"which is not an ancestor of {head}; publishing would "
"discard or rewrite published history, fail closed"
)
reasons.extend(anc.get("reasons") or [])
elif remote_exists and not remote_head:
reasons.append(
f"remote branch '{branch}' exists but its head could not be read; "
"fail closed"
)
# ── competing claims ──────────────────────────────────────────────────
competing = [p for p in (competing_open_prs or ()) if p]
evidence["competing_open_prs"] = list(competing)
if competing:
reasons.append(
f"open pull request(s) {competing} already claim issue {issue_number} "
"or this branch; ambiguous ownership, fail closed"
)
# ── content verification before publication ───────────────────────────
if expected_file_hashes:
observed = (
observed_file_hashes if isinstance(observed_file_hashes, dict) else {}
)
mismatches: list[str] = []
for path, expected_digest in dict(expected_file_hashes).items():
actual = observed.get(path)
if actual is None:
mismatches.append(f"{path}: missing or unreadable in the worktree")
elif _text(actual).lower() != _text(expected_digest).lower():
mismatches.append(
f"{path}: expected {expected_digest}, observed {actual}"
)
evidence["hash_mismatches"] = mismatches
evidence["file_hashes_verified"] = not mismatches
if mismatches:
reasons.append(
"declared content hashes do not match the worktree: "
+ "; ".join(mismatches)
+ ". Refusing to publish content that is not what was recorded"
)
if reasons:
return {
"outcome": REFUSED,
"publish_sanctioned": False,
"already_published": False,
"reasons": reasons,
"evidence": evidence,
}
return {
"outcome": ALREADY_PUBLISHED if already_published else PUBLISH_SANCTIONED,
# Idempotent retry: a remote head that already equals the assessed commit
# needs no second push, so the caller verifies instead of acting.
"publish_sanctioned": not already_published,
"already_published": already_published,
"reasons": [],
"evidence": evidence,
}
def publish_commit_to_remote_branch(
*,
worktree_path: str,
remote_name: str,
branch_name: str,
expected_head: str,
) -> dict:
"""Send exactly *expected_head* to ``refs/heads/<branch_name>``.
The refspec names the commit SHA explicitly rather than ``HEAD`` or the
local branch, so what lands is the commit that was assessed and nothing
else. No force, no lease, no ``+`` prefix: a non-fast-forward is rejected by
git itself, the last of several independent guards against overwriting
published history.
"""
path = _text(worktree_path)
remote = _text(remote_name)
branch = _text(branch_name)
head = _text(expected_head)
result: dict = {
"success": False,
"pushed_ref": f"refs/heads/{branch}" if branch else None,
"pushed_sha": head or None,
"stderr": None,
"reasons": [],
}
if not (path and remote and branch and head):
result["reasons"].append(
"publication requires a worktree path, remote, branch, and commit SHA"
)
return result
refspec = f"{head}:refs/heads/{branch}"
try:
res = subprocess.run(
["git", "-C", path, "push", remote, refspec],
capture_output=True,
text=True,
check=False,
)
except OSError as exc:
result["reasons"].append(f"publication could not run: {exc}")
return result
if res.returncode != 0:
# Redact before surfacing: failures can echo credentialed remote URLs.
result["stderr"] = redact_command(res.stderr or "")
result["reasons"].append(
f"publication of {head} to '{branch}' on remote '{remote}' failed"
)
return result
result["success"] = True
return result
def verify_published_head(
*, worktree_path: str, remote_name: str, branch_name: str, expected_head: str
) -> dict:
"""Read-after-write: confirm the remote head equals *expected_head* (AC20)."""
probe = read_remote_branch_head(worktree_path, remote_name, branch_name)
head = _text(expected_head).lower()
observed = _text(probe.get("remote_head_sha")).lower() or None
verified = bool(head) and bool(probe.get("probe_ok")) and observed == head
reasons: list[str] = list(probe.get("reasons") or [])
if probe.get("probe_ok") and not verified:
reasons.append(
"read-after-write verification failed: remote head is "
f"{observed or '(absent)'}, expected {head}"
)
return {
"verified": verified,
"remote_head_sha": observed,
"expected_head": head or None,
"reasons": reasons,
}
-1
View File
@@ -120,7 +120,6 @@ that gates each call, not which tools exist.
- `gitea_observability_list_projects`
- `gitea_observability_reconcile_incident`
- `gitea_post_heartbeat`
- `gitea_publish_unpublished_issue_branch`
- `gitea_quarantine_contaminated_review`
- `gitea_reclaim_expired_workflow_lease`
- `gitea_reconcile_already_landed_pr`
+81 -1
View File
@@ -48,7 +48,8 @@ that govern when a write path may open (#632, epic #631).
| Path | Description |
|------|-------------|
| `/` | Home / operator overview |
| `/health` | JSON liveness (`status`, `service`, `mode`, `timestamp`) |
| `/health` | JSON liveness (`status`, `service`, `mode`, `timestamp`, `uptime_seconds`) |
| `/api/v1/system/health` | Structured read-only system health (#634) |
| `/queue` | Live PR and issue queue dashboard (#429) |
| `/api/queue` | JSON queue export with pagination metadata |
| `/projects` | Project registry list (#427) |
@@ -72,6 +73,85 @@ Most routes are GET-only. POST/PUT/PATCH/DELETE return `405` with
`read-only-mvp`, except `/audit` and `/api/audit` which accept POST for
local validator preview only (no Gitea mutations, no server-side storage).
## System health API (#634)
`GET /api/v1/system/health` is the structured, read-only health surface for
automated readiness checks. It is the first console API under the `/api/v1`
prefix; the unversioned MVP exports remain as compatibility aliases.
`/health` is unchanged for existing consumers — every MVP key is still present
— and now also carries `started_at`, `uptime_seconds`, and a
`system_health_api` pointer. It stays deliberately cheap and runs no dependency
probe, because answering readiness costs real work.
**Status codes.** `200` when ready, `503` when a required dependency failed or
was never probed. Automation can branch on the code without parsing the body.
**Query flags.** The Gitea check is a network call, so it is opt-in:
`GET /api/v1/system/health?deep=1` runs it and caches the result for
`WEBUI_HEALTH_PROBE_TTL_SECONDS` (default 15s) so dashboard polling does not
amplify into remote load. Without the flag that probe reports `skipped`.
**Dependencies.** `control_plane_db` and `repository` are required and drive
readiness. `gitea` is optional: when it fails the overall `status` degrades but
`readiness.ready` stays true, because local inventory is still serveable. Each
entry carries `status`, `detail`, `required`, and `latency_ms`.
Two honesty rules are worth knowing before reading the payload:
* `stale_runtime.mutation_safe` is true only when the runtime, checkout, and
remote-tracking commits are all known and equal. An unfetched remote is
reported as indeterminate, never as safe.
* `mcp_namespaces` entries are always `unproven`. A web process runs outside
the IDE-managed MCP client and cannot invoke a namespace tool, so per #543
only a `client_namespace` probe can prove that path.
Sample response (abridged, healthy):
```json
{
"status": "ok",
"service": "mcp-control-plane-webui",
"mode": "read-only",
"api": "/api/v1/system/health",
"timestamp": "2026-07-22T11:04:18.512034+00:00",
"readiness": { "ready": true, "complete": true, "reasons": [] },
"version": {
"git_sha": "620ed6e9a9550b8da2ceb82d9ab8744e8920490f",
"git_describe": "v1.1.0-898-g620ed6e",
"control_plane_schema_version": 4,
"python_version": "3.14.5",
"known": true
},
"process": { "started_at": "2026-07-22T10:58:02.114+00:00", "uptime_seconds": 376.4 },
"deep_probes_requested": false,
"dependencies": [
{
"name": "control_plane_db",
"kind": "sqlite",
"status": "ok",
"detail": "schema v4 readable",
"required": true,
"healthy": true,
"latency_ms": 1.482,
"metadata": { "schema_version": 4, "active_leases": 3 }
},
{ "name": "repository", "kind": "git", "status": "ok", "required": true, "healthy": true },
{ "name": "gitea", "kind": "http", "status": "skipped", "required": false, "healthy": false }
],
"mcp_namespaces": [
{ "namespace": "gitea-author", "required_tool": "gitea_whoami", "status": "unproven" }
],
"stale_runtime": { "stale": false, "determinable": true, "mutation_safe": true, "reasons": [] },
"probe_errors": []
}
```
No restart, reload, or process-kill control is exposed here: those are Phase 2
at the earliest, and #630 forbids process-kill recovery outright. Every probe
opens its subject read-only — the control-plane database is opened through a
`mode=ro` URI so a health check can never create or migrate a schema.
## Report audit (#431)
Paste an LLM final report at `/audit` or POST JSON to `/api/audit`. The UI
-232
View File
@@ -2024,7 +2024,6 @@ import review_quarantine # noqa: E402 # #695 contaminated formal-review quaran
import mcp_daemon_guard # noqa: E402 # #695 native transport provenance
import already_landed_reconcile # noqa: E402
import author_mutation_worktree # noqa: E402
import branch_publish # noqa: E402 # #812 AC20 unpublished-commit publication
import root_checkout_guard # noqa: E402
import workflow_scope_guard # noqa: E402 # #683 production scope / force-on guards
import stable_branch_push_guard # noqa: E402
@@ -9083,237 +9082,6 @@ def gitea_commit_files(
}
def _publication_block(reasons: list[str], **extra) -> dict:
"""Uniform fail-closed shape for publication refusals (#812 AC20)."""
payload = {
"success": False,
"performed": False,
"published": False,
"verified": False,
"outcome": branch_publish.REFUSED,
"reasons": reasons,
# State every record this operation left alone, so a refusal can never
# be misread as a lock mutation (#812 AC23).
"issue_lock_record_mutated": False,
"workflow_lease_touched": False,
}
payload.update(extra)
return payload
@mcp.tool()
def gitea_publish_unpublished_issue_branch(
issue_number: int,
branch_name: str,
worktree_path: str,
expected_head: str,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
git_remote_name: str | None = None,
expected_file_hashes: dict | None = None,
dry_run: bool = False,
) -> dict:
"""Publish an already-committed, unpublished issue branch (#812 AC20).
Creates the remote head for a branch whose work is *already* a local commit
on a registered, clean worktree, so exact-owner lease renewal
(``issue_lock_renewal``) has the published head its evidence model requires.
This is the one step of the entry point B deadlock that no existing tool can
perform: publication is otherwise lock-derived under #618, and the lock
itself is withheld until a remote head exists.
Not a lock bypass. The branch's **durable issue-lock record must already
name the caller as claimant** ownership is read from the lock file, never
asserted by the caller so this can only publish work the caller already
owns. It refuses a dirty or untracked-carrying worktree, an unregistered
worktree, a changed local HEAD, a remote head that is not an ancestor of the
commit, a competing open PR on another branch for the same issue, and any
declared-hash mismatch. It renews, reclaims, and clears nothing: the durable
issue-lock file and the control-plane workflow lease are both left untouched
(#812 AC23), and the recorded owner pid's liveness is never consulted or
asserted (#812 AC24).
Args:
issue_number: The issue whose recorded claim authorizes publication.
branch_name: Issue branch to publish, ``(fix|feat|docs|chore)/issue-N-``.
worktree_path: Registered worktree holding the commit.
expected_head: Full 40-character SHA of the commit to publish. Required:
publication names the exact commit, and a mismatch fails closed.
remote: Known instance 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
git_remote_name: Git remote to publish to; defaults to *remote*.
expected_file_hashes: Optional ``{path: sha256}`` verified against the
worktree before publication. Any mismatch or missing file refuses.
dry_run: Report the decision and evidence, mutate nothing.
Returns:
dict with 'success', 'performed', 'published', 'verified', 'outcome',
'remote_head_sha', 'reasons', and 'evidence'.
"""
task = "publish_unpublished_branch"
ok, block_reasons = role_session_router.check_author_mutation_after_reviewer_stop(
task
)
if not ok:
return _publication_block(block_reasons)
blocked = _namespace_mutation_block(task, remote=remote)
if blocked:
return blocked
blocked = _profile_permission_block(
task_capability_map.required_permission(task),
remote=remote, host=host, org=org, repo=repo,
org_explicit=org is not None,
repo_explicit=repo is not None,
)
if blocked:
return blocked
verify_preflight_purity(remote, task=task, org=org, repo=repo)
h, o, r = _resolve(remote, host, org, repo)
git_remote = (git_remote_name or remote or "").strip()
workspace = os.path.realpath(os.path.abspath((worktree_path or "").strip() or "."))
existing_lock = issue_lock_store.load_issue_lock(
remote=remote, org=o, repo=r, issue_number=int(issue_number)
)
git_state = issue_lock_worktree.read_worktree_git_state(workspace)
registered = author_mutation_worktree.path_in_git_worktree_list(
workspace, PROJECT_ROOT
)
remote_probe = branch_publish.read_remote_branch_head(
workspace, git_remote, branch_name
)
ancestry = None
probe_head = (remote_probe.get("remote_head_sha") or "").strip()
if probe_head and probe_head.lower() != (expected_head or "").strip().lower():
ancestry = branch_publish.read_is_ancestor(workspace, probe_head, expected_head)
# A PR on this very branch is this work's own PR, not a rival claim. Only an
# open PR for the same issue on a *different* branch is a competing claim.
competing: list = []
for pull in _list_open_pulls(h, o, r, _auth(h)):
ref = str((pull.get("head") or {}).get("ref") or "")
if not ref or ref == branch_name:
continue
if issue_lock_adoption.branch_carries_issue_marker(ref, int(issue_number)):
competing.append(pull.get("number"))
observed_hashes = None
if expected_file_hashes:
observed_hashes = branch_publish.hash_worktree_files(
workspace, list(expected_file_hashes.keys())
)
claimant = _work_lease_claimant(h)
assessment = branch_publish.assess_unpublished_commit_publication(
existing_lock,
issue_number=int(issue_number),
branch_name=branch_name,
worktree_path=workspace,
expected_head=expected_head,
remote=remote,
org=o,
repo=r,
identity=claimant.get("username"),
profile=claimant.get("profile"),
worktree_state=git_state,
worktree_registered=registered,
remote_probe=remote_probe,
ancestry=ancestry,
competing_open_prs=competing,
expected_file_hashes=expected_file_hashes,
observed_file_hashes=observed_hashes,
)
if assessment["outcome"] == branch_publish.REFUSED:
return _publication_block(
assessment["reasons"], evidence=assessment["evidence"]
)
if dry_run:
return {
"success": True,
"performed": False,
"published": False,
"verified": False,
"dry_run": True,
"outcome": assessment["outcome"],
"would_publish": assessment["publish_sanctioned"],
"remote_head_sha": assessment["evidence"].get("remote_head_sha"),
"reasons": [],
"evidence": assessment["evidence"],
"issue_lock_record_mutated": False,
"workflow_lease_touched": False,
}
performed = False
if assessment["publish_sanctioned"]:
with _audited(
task, host=h, remote=remote, org=o, repo=r,
target_branch=branch_name,
request_metadata={
"issue_number": int(issue_number),
"expected_head": expected_head,
"git_remote": git_remote,
},
):
push = branch_publish.publish_commit_to_remote_branch(
worktree_path=workspace,
remote_name=git_remote,
branch_name=branch_name,
expected_head=expected_head,
)
if not push.get("success"):
return _publication_block(
push.get("reasons") or ["publication failed"],
evidence=assessment["evidence"],
stderr=push.get("stderr"),
)
performed = True
verification = branch_publish.verify_published_head(
worktree_path=workspace,
remote_name=git_remote,
branch_name=branch_name,
expected_head=expected_head,
)
if not verification.get("verified"):
return _publication_block(
verification.get("reasons") or ["read-after-write verification failed"],
evidence=assessment["evidence"],
performed=performed,
published=performed,
remote_head_sha=verification.get("remote_head_sha"),
)
return {
"success": True,
"performed": performed,
"published": True,
"verified": True,
"outcome": assessment["outcome"],
"remote_head_sha": verification.get("remote_head_sha"),
"reasons": [],
"evidence": assessment["evidence"],
# Publication is the whole of this operation's authority (#812 AC23).
"issue_lock_record_mutated": False,
"workflow_lease_touched": False,
"exact_next_action": (
f"Remote head for '{branch_name}' is now observable. Call "
f"gitea_lock_issue(issue_number={int(issue_number)}, "
f"branch_name='{branch_name}', worktree_path='{workspace}') to renew "
"the exact-owner lease, then gitea_create_pr."
),
}
# Merge methods supported by the Gitea merge API.
_MERGE_METHODS = ("merge", "squash", "rebase")
-9
View File
@@ -62,14 +62,6 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.branch.push",
"role": "author",
},
# #812 AC20: publish an already-committed, unpublished local head so
# exact-owner lease renewal has an observable remote head to reason about.
# Same authority as any other author push — deliberately not a new
# operation name, so it cannot widen an already-configured author profile.
"publish_unpublished_branch": {
"permission": "gitea.branch.push",
"role": "author",
},
"create_pr": {
"permission": "gitea.pr.create",
"role": "author",
@@ -508,7 +500,6 @@ ROLE_EXCLUSIVE_TASKS: frozenset[str] = frozenset(
"gitea_release_merger_pr_lease",
"create_branch",
"push_branch",
"publish_unpublished_branch",
"create_pr",
"commit_files",
"gitea_commit_files",
@@ -1,650 +0,0 @@
"""Publication of an unpublished local commit (#812 AC20).
Entry point B of #812: a registered worktree, clean, on its issue branch,
holding a local commit that has never been published. Exact-owner lease renewal
refuses such a claim for want of an observable remote head, and every existing
publication path is lock-derived, so the two predicates close a cycle around
work that is otherwise complete.
These tests exercise the disposition through its *evidence*, never through any
particular issue number: every case uses an arbitrary issue number against a
synthetic repository, and the same assertions hold for any other. Nothing here
reads, writes, or references the live protected worktree named in #812 AC17 —
that content is preserved evidence for the duration of this work, so the
fixtures below build their own repositories from scratch.
The remote is a local bare repository, so publication and read-after-write
verification are genuinely executed rather than mocked.
"""
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import branch_publish # noqa: E402
import issue_lock_provenance # noqa: E402
import issue_lock_renewal # noqa: E402
import issue_lock_store # noqa: E402
import mcp_server # noqa: E402
from mutation_profile_fixture import shared_mutation_env # noqa: E402
ISSUE = 9812
BRANCH = f"feat/issue-{ISSUE}-publish-fixture"
IDENTITY = "example-user"
PROFILE = "test-author-prgs"
ORG = "Scaled-Tech-Consulting"
REPO = "Gitea-Tools"
GIT_REMOTE = "prgs"
def _ts(hours: int) -> str:
return (
(datetime.now(timezone.utc) + timedelta(hours=hours))
.isoformat()
.replace("+00:00", "Z")
)
class _PublishBase(unittest.TestCase):
"""Real git repo + real bare remote + durable lock naming the caller.
The recorded owner pid is deliberately **this live process**. That mirrors
the production shape #812 documents, where the pid belongs to a long-running
MCP daemon rather than to a dead author client, and it proves publication
never depends on a dead process (#812 AC24).
"""
def setUp(self):
self.lock_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.lock_dir.cleanup)
self.origin = tempfile.mkdtemp(prefix="issue812-origin-")
self.repo = tempfile.mkdtemp(prefix="issue812-work-")
for path in (self.origin, self.repo):
self.addCleanup(
lambda p=path: subprocess.run(["rm", "-rf", p], check=False)
)
self._init_repos()
self.remotes = patch.dict(
mcp_server.REMOTES,
{"prgs": {"host": "gitea.prgs.cc", "org": ORG, "repo": REPO}},
)
self.remotes.start()
self.addCleanup(patch.stopall)
mcp_server._IDENTITY_CACHE.clear()
# ── fixture construction ─────────────────────────────────────────────
def _git(self, *args, cwd=None):
return subprocess.run(
["git", "-C", cwd or self.repo, *args],
capture_output=True,
text=True,
check=True,
)
def _init_repos(self):
subprocess.run(
["git", "init", "-q", "--bare", "-b", "master", self.origin], check=True
)
self._git("init", "-q", "-b", "master")
self._git("config", "user.email", "[email protected]")
self._git("config", "user.name", "Test")
self._git("remote", "add", GIT_REMOTE, self.origin)
with open(os.path.join(self.repo, "seed.txt"), "w") as fh:
fh.write("seed\n")
self._git("add", "seed.txt")
self._git("commit", "-q", "-m", "seed")
self.base_sha = self._git("rev-parse", "HEAD").stdout.strip()
self._git("push", "-q", GIT_REMOTE, "master")
self._git("checkout", "-q", "-b", BRANCH)
with open(os.path.join(self.repo, "work.txt"), "w") as fh:
fh.write("unpublished implementation\n")
self._git("add", "work.txt")
self._git("commit", "-q", "-m", "unpublished implementation")
self.head_sha = self._git("rev-parse", "HEAD").stdout.strip()
self.worktree = os.path.realpath(self.repo)
def lock_path(self):
return issue_lock_store.lock_file_path(
remote="prgs", org=ORG, repo=REPO, issue_number=ISSUE,
lock_dir=self.lock_dir.name,
)
def write_lock(self, **overrides):
path = self.lock_path()
claimant = overrides.pop(
"claimant", {"username": IDENTITY, "profile": PROFILE}
)
pid = overrides.pop("session_pid", os.getpid())
lease = {
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
"issue_number": ISSUE,
"pr_number": None,
"branch": overrides.get("branch_name", BRANCH),
"worktree_path": overrides.get("worktree_path", self.worktree),
"claimant": claimant,
"created_at": _ts(-2),
"last_heartbeat_at": _ts(-2),
# Expired: entry point B's lease has lapsed, which is precisely why
# renewal — and therefore a published head — is needed.
"expires_at": _ts(-1),
}
lease.update(overrides.pop("work_lease", {}))
data = {
"issue_number": ISSUE,
"branch_name": BRANCH,
"remote": "prgs",
"org": ORG,
"repo": REPO,
"worktree_path": self.worktree,
"session_pid": pid,
"pid": pid,
"lock_generation": 1,
"work_lease": lease,
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
tool="gitea_lock_issue", claimant=claimant
),
}
data.update(overrides)
data["lock_file_path"] = path
issue_lock_store.save_lock_file(path, data)
return path
def _tool_env(self):
env = shared_mutation_env(
PROFILE, include_example_repo=True,
GITEA_ISSUE_LOCK_DIR=self.lock_dir.name,
)
env["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
# These tests repoint PROJECT_ROOT at a synthetic repository so the
# registered-worktree proof runs for real. Pin the parity gate to the
# server's own startup head so that repointing does not read as a stale
# daemon; the gate itself stays live and enforced.
startup_head = mcp_server._STARTUP_PARITY.get("startup_head") or ""
env["GITEA_TEST_CURRENT_HEAD"] = startup_head
env["GITEA_TEST_LIVE_REMOTE_HEAD"] = startup_head
return env
# ── tool driver ──────────────────────────────────────────────────────
def run_publish(self, *, open_prs=None, expected_head=None, **kwargs):
"""Drive the public publication tool against the synthetic fixture."""
env = self._tool_env()
with patch(
"mcp_server._list_open_pulls", return_value=list(open_prs or [])
), patch(
"mcp_server._auth", return_value="token x"
), patch(
"mcp_server.get_auth_header", return_value="token x"
), patch(
"mcp_server._work_lease_claimant",
return_value={"username": IDENTITY, "profile": PROFILE},
), patch.object(
mcp_server, "PROJECT_ROOT", self.repo
), patch.dict(os.environ, env, clear=True):
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
return mcp_server.gitea_publish_unpublished_issue_branch(
issue_number=kwargs.pop("issue_number", ISSUE),
branch_name=kwargs.pop("branch_name", BRANCH),
worktree_path=kwargs.pop("worktree_path", self.worktree),
expected_head=expected_head or self.head_sha,
remote="prgs",
git_remote_name=kwargs.pop("git_remote_name", GIT_REMOTE),
**kwargs,
)
def remote_head(self, branch=BRANCH):
res = subprocess.run(
["git", "-C", self.origin, "rev-parse", "--verify", "--quiet", branch],
capture_output=True, text=True, check=False,
)
return (res.stdout or "").strip() or None
class TestSuccessfulPublication(_PublishBase):
"""AC20 — the branch becomes observable and is verified after the write."""
def test_publishes_clean_unpublished_commit(self):
self.write_lock()
self.assertIsNone(self.remote_head(), "fixture must start unpublished")
result = self.run_publish()
self.assertTrue(result["success"], result.get("reasons"))
self.assertTrue(result["performed"])
self.assertTrue(result["published"])
self.assertTrue(result["verified"], "read-after-write must be proven")
self.assertEqual(result["remote_head_sha"], self.head_sha)
self.assertEqual(self.remote_head(), self.head_sha)
def test_publication_does_not_rewrite_the_commit(self):
self.write_lock()
self.run_publish()
# The published object is the same commit, not a copy or a rewrite.
self.assertEqual(self.remote_head(), self.head_sha)
self.assertEqual(
self._git("rev-parse", "HEAD").stdout.strip(), self.head_sha
)
def test_exact_next_action_names_the_lock_call(self):
self.write_lock()
result = self.run_publish()
self.assertIn("gitea_lock_issue", result["exact_next_action"])
class TestFailsClosed(_PublishBase):
"""AC20/AC9 — each refusal reason, exercised independently."""
def test_changed_local_head_refuses(self):
self.write_lock()
stale = self.base_sha # a real commit, but not the declared head
result = self.run_publish(expected_head=stale)
self.assertFalse(result["success"])
self.assertTrue(
any("local commit changed" in r for r in result["reasons"]),
result["reasons"],
)
self.assertIsNone(self.remote_head(), "refusal must not publish")
def test_abbreviated_sha_refuses(self):
self.write_lock()
result = self.run_publish(expected_head=self.head_sha[:8])
self.assertFalse(result["success"])
self.assertTrue(
any("40-character" in r for r in result["reasons"]), result["reasons"]
)
def test_dirty_tracked_worktree_refuses(self):
self.write_lock()
with open(os.path.join(self.repo, "work.txt"), "a") as fh:
fh.write("uncommitted edit\n")
result = self.run_publish()
self.assertFalse(result["success"])
self.assertTrue(
any("dirty tracked files" in r for r in result["reasons"]),
result["reasons"],
)
self.assertIn("work.txt", result["evidence"]["dirty_tracked_files"])
self.assertIsNone(self.remote_head())
def test_untracked_file_refuses(self):
self.write_lock()
with open(os.path.join(self.repo, "stray.txt"), "w") as fh:
fh.write("not committed\n")
result = self.run_publish()
self.assertFalse(result["success"])
self.assertTrue(
any("untracked files" in r for r in result["reasons"]), result["reasons"]
)
self.assertIn("stray.txt", result["evidence"]["untracked_files"])
self.assertIsNone(self.remote_head())
def test_unexpected_remote_head_refuses(self):
"""A remote head that is not an ancestor must never be overwritten."""
self.write_lock()
# Publish a divergent commit to the branch from a separate line.
self._git("checkout", "-q", "-b", "divergent", self.base_sha)
with open(os.path.join(self.repo, "other.txt"), "w") as fh:
fh.write("someone else's work\n")
self._git("add", "other.txt")
self._git("commit", "-q", "-m", "divergent")
divergent = self._git("rev-parse", "HEAD").stdout.strip()
self._git("push", "-q", GIT_REMOTE, f"{divergent}:refs/heads/{BRANCH}")
self._git("checkout", "-q", BRANCH)
result = self.run_publish()
self.assertFalse(result["success"])
self.assertTrue(
any("not an ancestor" in r for r in result["reasons"]), result["reasons"]
)
self.assertEqual(
self.remote_head(), divergent, "the other head must survive intact"
)
def test_fast_forward_remote_head_is_allowed(self):
"""An ancestor head is an honest fast-forward, not a conflict."""
self.write_lock()
self._git("push", "-q", GIT_REMOTE, f"{self.base_sha}:refs/heads/{BRANCH}")
result = self.run_publish()
self.assertTrue(result["success"], result.get("reasons"))
self.assertTrue(result["evidence"]["fast_forward_from_remote"])
self.assertEqual(self.remote_head(), self.head_sha)
def test_content_hash_mismatch_refuses(self):
self.write_lock()
wrong = {"work.txt": "0" * 64}
result = self.run_publish(expected_file_hashes=wrong)
self.assertFalse(result["success"])
self.assertTrue(
any("declared content hashes" in r for r in result["reasons"]),
result["reasons"],
)
self.assertFalse(result["evidence"]["file_hashes_verified"])
self.assertIsNone(self.remote_head())
def test_matching_content_hashes_publish(self):
self.write_lock()
digests = branch_publish.hash_worktree_files(self.worktree, ["work.txt"])
result = self.run_publish(expected_file_hashes=digests)
self.assertTrue(result["success"], result.get("reasons"))
self.assertTrue(result["evidence"]["file_hashes_verified"])
def test_missing_declared_file_refuses(self):
self.write_lock()
result = self.run_publish(expected_file_hashes={"absent.txt": "0" * 64})
self.assertFalse(result["success"])
self.assertTrue(
any("missing or unreadable" in r for r in result["reasons"]),
result["reasons"],
)
def test_foreign_claimant_refuses(self):
"""Ownership comes from the durable record, not from the caller."""
self.write_lock(claimant={"username": "someone-else", "profile": PROFILE})
result = self.run_publish()
self.assertFalse(result["success"])
self.assertTrue(
any("foreign claim" in r for r in result["reasons"]), result["reasons"]
)
self.assertIsNone(self.remote_head())
def test_foreign_profile_refuses(self):
self.write_lock(
claimant={"username": IDENTITY, "profile": "test-reviewer-prgs"}
)
result = self.run_publish()
self.assertFalse(result["success"])
self.assertTrue(
any("claimant profile" in r for r in result["reasons"]), result["reasons"]
)
def test_absent_lock_record_refuses(self):
"""No recorded claim means this cannot be used to bypass the lock."""
result = self.run_publish() # no write_lock()
self.assertFalse(result["success"])
self.assertTrue(
any("no durable issue-lock record" in r for r in result["reasons"]),
result["reasons"],
)
self.assertIsNone(self.remote_head())
def test_branch_mismatch_against_lock_refuses(self):
self.write_lock(branch_name=f"feat/issue-{ISSUE}-different")
result = self.run_publish()
self.assertFalse(result["success"])
self.assertTrue(
any("records branch" in r for r in result["reasons"]), result["reasons"]
)
def test_worktree_mismatch_against_lock_refuses(self):
self.write_lock(worktree_path="/tmp/some/other/worktree")
result = self.run_publish()
self.assertFalse(result["success"])
self.assertTrue(
any("records worktree" in r for r in result["reasons"]), result["reasons"]
)
def test_competing_open_pr_on_another_branch_refuses(self):
self.write_lock()
competing = [{"number": 4242, "head": {"ref": f"fix/issue-{ISSUE}-rival"}}]
result = self.run_publish(open_prs=competing)
self.assertFalse(result["success"])
self.assertTrue(
any("already claim issue" in r for r in result["reasons"]),
result["reasons"],
)
self.assertIsNone(self.remote_head())
def test_open_pr_on_the_same_branch_is_not_competing(self):
"""This branch's own PR is not a rival claim against itself."""
self.write_lock()
own = [{"number": 77, "head": {"ref": BRANCH}}]
result = self.run_publish(open_prs=own)
self.assertTrue(result["success"], result.get("reasons"))
class TestGuardStrictnessPreserved(_PublishBase):
"""AC15 — publication is an operation, never a weakening of the guards."""
def test_non_issue_branch_refuses(self):
self._git("checkout", "-q", "-b", "scratch/not-issue-linked")
self.write_lock(branch_name="scratch/not-issue-linked")
result = self.run_publish(branch_name="scratch/not-issue-linked")
self.assertFalse(result["success"])
self.assertTrue(
any("issue-linked" in r for r in result["reasons"]), result["reasons"]
)
def test_stable_branch_refuses(self):
self.write_lock(branch_name="master")
result = self.run_publish(branch_name="master")
self.assertFalse(result["success"])
self.assertTrue(
any("issue-linked" in r or "stable branch" in r for r in result["reasons"]),
result["reasons"],
)
def test_branch_number_must_match_the_issue(self):
other = "feat/issue-7777-mismatched"
self._git("checkout", "-q", "-b", other)
self.write_lock(branch_name=other)
result = self.run_publish(branch_name=other)
self.assertFalse(result["success"])
self.assertTrue(
any("does not carry issue number" in r for r in result["reasons"]),
result["reasons"],
)
def test_unregistered_worktree_refuses(self):
"""#713 — an improvised directory is not a registered worktree."""
path = self.write_lock()
assessment = branch_publish.assess_unpublished_commit_publication(
issue_lock_store.read_lock_file(path),
issue_number=ISSUE, branch_name=BRANCH, worktree_path=self.worktree,
expected_head=self.head_sha, remote="prgs", org=ORG, repo=REPO,
identity=IDENTITY, profile=PROFILE,
worktree_state={
"current_branch": BRANCH, "porcelain_status": "",
"head_sha": self.head_sha,
},
worktree_registered=False,
remote_probe={"probe_ok": True, "remote_branch_exists": False},
)
self.assertEqual(assessment["outcome"], branch_publish.REFUSED)
self.assertTrue(
any("not listed in git worktree list" in r
for r in assessment["reasons"]),
assessment["reasons"],
)
def test_unobservable_remote_refuses(self):
"""An unknown remote state must not be mistaken for an absent branch."""
self.write_lock()
result = self.run_publish(git_remote_name="no-such-remote")
self.assertFalse(result["success"])
self.assertTrue(
any("could not be observed" in r for r in result["reasons"]),
result["reasons"],
)
class TestRecordSeparation(_PublishBase):
"""AC23 — the durable issue lock and the workflow lease are distinct."""
def test_publication_leaves_the_issue_lock_byte_identical(self):
path = self.write_lock()
with open(path, "rb") as fh:
before = fh.read()
result = self.run_publish()
self.assertTrue(result["success"], result.get("reasons"))
with open(path, "rb") as fh:
after = fh.read()
self.assertEqual(before, after, "publication must not mutate the lock record")
self.assertFalse(result["issue_lock_record_mutated"])
self.assertFalse(result["workflow_lease_touched"])
def test_refusal_also_reports_untouched_records(self):
result = self.run_publish() # refuses: no lock record
self.assertFalse(result["issue_lock_record_mutated"])
self.assertFalse(result["workflow_lease_touched"])
def test_lock_generation_is_not_advanced(self):
path = self.write_lock()
self.run_publish()
lock = issue_lock_store.read_lock_file(path)
self.assertEqual(lock["lock_generation"], 1)
class TestTruthfulProcessEvidence(_PublishBase):
"""AC24 — a live daemon pid is never represented as a dead process."""
def test_live_recorded_pid_does_not_block_publication(self):
# The recorded pid is this live process, standing in for the live MCP
# daemon. Reclaim would refuse here; publication legitimately does not.
path = self.write_lock(session_pid=os.getpid())
lock = issue_lock_store.read_lock_file(path)
self.assertEqual(lock["pid"], os.getpid())
result = self.run_publish()
self.assertTrue(result["success"], result.get("reasons"))
self.assertEqual(self.remote_head(), self.head_sha)
def test_liveness_is_not_consulted_as_evidence(self):
self.write_lock(session_pid=os.getpid())
result = self.run_publish()
self.assertFalse(result["evidence"]["owner_pid_liveness_consulted"])
def test_reclaim_still_refuses_for_the_same_live_pid(self):
"""Publication does not soften the reclaim predicate it routes around."""
path = self.write_lock(session_pid=os.getpid())
lock = issue_lock_store.read_lock_file(path)
reclaim = issue_lock_store.assess_expired_lock_reclaim(lock)
self.assertFalse(reclaim["reclaim_allowed"])
class TestIdempotentRetry(_PublishBase):
"""AC20 — retry is safe and read-after-write is proven every time."""
def test_second_publication_reports_already_published(self):
self.write_lock()
first = self.run_publish()
self.assertTrue(first["performed"])
second = self.run_publish()
self.assertTrue(second["success"], second.get("reasons"))
self.assertFalse(second["performed"], "no second push is needed")
self.assertTrue(second["published"])
self.assertTrue(second["verified"])
self.assertEqual(second["outcome"], branch_publish.ALREADY_PUBLISHED)
self.assertEqual(self.remote_head(), self.head_sha)
class TestDryRun(_PublishBase):
"""AC12 — dry run reports the decision and mutates nothing."""
def test_dry_run_reports_intent_without_publishing(self):
self.write_lock()
result = self.run_publish(dry_run=True)
self.assertTrue(result["success"])
self.assertTrue(result["dry_run"])
self.assertTrue(result["would_publish"])
self.assertFalse(result["performed"])
self.assertIsNone(self.remote_head(), "dry run must not publish")
def test_dry_run_and_apply_agree_on_a_refusal(self):
"""AC11 — the reported decision does not depend on which mode ran."""
self.write_lock(claimant={"username": "someone-else", "profile": PROFILE})
dry = self.run_publish(dry_run=True)
applied = self.run_publish()
self.assertFalse(dry["success"])
self.assertFalse(applied["success"])
self.assertEqual(dry["reasons"], applied["reasons"])
class TestRenewalUnblocked(_PublishBase):
"""AC20/AC21 — renewal is permitted only after verified publication."""
def _renewal(self, remote_head):
return issue_lock_renewal.assess_exact_owner_lease_renewal(
issue_lock_store.read_lock_file(self.lock_path()),
issue_number=ISSUE, branch_name=BRANCH, worktree_path=self.worktree,
remote="prgs", org=ORG, repo=REPO,
identity=IDENTITY, profile=PROFILE,
current_branch=BRANCH, porcelain_status="", worktree_exists=True,
head_sha=self.head_sha, remote_head_sha=remote_head,
)
def test_renewal_refuses_before_publication(self):
self.write_lock()
decision = self._renewal(None)
self.assertFalse(decision["renewal_sanctioned"])
self.assertTrue(
any("unpublished branch" in r for r in decision["reasons"]),
decision["reasons"],
)
def test_renewal_is_sanctioned_after_publication(self):
self.write_lock()
result = self.run_publish()
self.assertTrue(result["verified"], result.get("reasons"))
decision = self._renewal(self.remote_head())
self.assertTrue(decision["renewal_sanctioned"], decision["reasons"])
class TestProtectedAssetUntouched(unittest.TestCase):
"""AC17 — no test or fixture may reference the protected worktree."""
def test_no_reference_to_the_protected_worktree(self):
here = os.path.dirname(os.path.abspath(__file__))
root = os.path.dirname(here)
needle = "issue-635-project-registry" + "-api"
for path in (
os.path.join(here, "test_issue_812_publish_unpublished_commit.py"),
os.path.join(root, "branch_publish.py"),
):
with open(path, "r", encoding="utf-8") as fh:
body = fh.read()
self.assertNotIn(needle, body)
if __name__ == "__main__": # pragma: no cover
unittest.main()
@@ -139,9 +139,6 @@ EXPECTED_ROLE_EXCLUSIVE_TASKS = frozenset(
"gitea_release_merger_pr_lease",
"create_branch",
"push_branch",
# #812 AC20: publishing an unpublished local head is author-only for the
# same reason every other push is — it writes a branch to the remote.
"publish_unpublished_branch",
"create_pr",
"commit_files",
"gitea_commit_files",
+499
View File
@@ -0,0 +1,499 @@
"""Tests for the read-only system-health API (#634).
Covers the acceptance criteria directly: a structured payload with readiness
and a dependency list (AC1), version and uptime when knowable (AC2), stale
runtime reported without a false mutation-safe claim (AC3), and the healthy /
degraded-dependency / redaction cases (AC4).
"""
import json
import os
import sqlite3
import sys
import tempfile
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
import control_plane_db
from webui.app import create_app
from webui.deployment_boundary import scan_text_for_client_secrets
from webui.system_health import (
API_PATH,
STATUS_DEGRADED,
STATUS_DOWN,
STATUS_OK,
STATUS_SKIPPED,
DependencyProbe,
StaleRuntime,
assess_stale_runtime,
clear_probe_cache,
load_system_health,
namespace_summaries,
probe_control_plane_db,
probe_gitea,
process_uptime,
redact,
redact_url,
snapshot_to_dict,
)
def _probe(name, status, *, required=True, detail="detail", kind="test"):
return DependencyProbe(
name=name,
kind=kind,
status=status,
detail=detail,
required=required,
latency_ms=1.5,
metadata={},
)
_ALL_HEALTHY = (
_probe("control_plane_db", STATUS_OK, kind="sqlite"),
_probe("repository", STATUS_OK, kind="git"),
_probe("gitea", STATUS_OK, required=False, kind="http"),
)
_CLEAN_PARITY = StaleRuntime(
daemon_head="abc123",
checkout_head="abc123",
remote_head="abc123",
stale=False,
determinable=True,
mutation_safe=True,
reasons=(),
)
class CleanParityMixin:
"""Pin parity for tests about aggregation rather than staleness.
Without this the assertions depend on the real checkout: a worktree whose
branch is ahead of its upstream is genuinely stale, which would degrade the
overall status and make these cases fail for an unrelated reason.
"""
def setUp(self):
super().setUp()
patcher = mock.patch(
"webui.system_health.assess_stale_runtime",
return_value=_CLEAN_PARITY,
)
patcher.start()
self.addCleanup(patcher.stop)
class TestDependencyAggregation(CleanParityMixin, unittest.TestCase):
"""AC1 — readiness and dependency list derived from probe results."""
def test_all_healthy_is_ok_and_ready(self):
snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc123")
self.assertEqual(snapshot.status, STATUS_OK)
self.assertTrue(snapshot.ready)
self.assertTrue(snapshot.readiness_complete)
self.assertEqual(snapshot.readiness_reasons, ())
self.assertEqual(len(snapshot.dependencies), 3)
def test_required_dependency_down_blocks_readiness(self):
probes = (
_probe("control_plane_db", STATUS_DOWN, detail="file missing", kind="sqlite"),
_probe("repository", STATUS_OK, kind="git"),
_probe("gitea", STATUS_OK, required=False, kind="http"),
)
snapshot = load_system_health(probes=probes, daemon_head="abc123")
self.assertEqual(snapshot.status, STATUS_DOWN)
self.assertFalse(snapshot.ready)
self.assertTrue(
any("control_plane_db" in reason for reason in snapshot.readiness_reasons)
)
def test_optional_dependency_down_degrades_but_stays_ready(self):
"""A failing optional probe must not claim the process itself is unready."""
probes = (
_probe("control_plane_db", STATUS_OK, kind="sqlite"),
_probe("repository", STATUS_OK, kind="git"),
_probe("gitea", STATUS_DOWN, required=False, detail="timeout", kind="http"),
)
snapshot = load_system_health(probes=probes, daemon_head="abc123")
self.assertEqual(snapshot.status, STATUS_DEGRADED)
self.assertTrue(snapshot.ready)
self.assertTrue(any("gitea" in reason for reason in snapshot.readiness_reasons))
def test_unrun_required_probe_leaves_readiness_incomplete(self):
"""Not probed is not the same as passing."""
probes = (
_probe("control_plane_db", STATUS_OK, kind="sqlite"),
_probe("repository", STATUS_SKIPPED, detail="offline", kind="git"),
)
snapshot = load_system_health(probes=probes, daemon_head="abc123")
self.assertFalse(snapshot.ready)
self.assertFalse(snapshot.readiness_complete)
self.assertEqual(snapshot.status, STATUS_DEGRADED)
def test_skipped_optional_probe_does_not_block_readiness(self):
probes = (
_probe("control_plane_db", STATUS_OK, kind="sqlite"),
_probe("repository", STATUS_OK, kind="git"),
_probe("gitea", STATUS_SKIPPED, required=False, kind="http"),
)
snapshot = load_system_health(probes=probes, daemon_head="abc123")
self.assertTrue(snapshot.ready)
self.assertTrue(snapshot.readiness_complete)
class TestVersionAndUptime(CleanParityMixin, unittest.TestCase):
"""AC2 — version and uptime present when knowable."""
def test_uptime_and_start_time_present(self):
snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc123")
self.assertGreaterEqual(snapshot.uptime_seconds, 0.0)
self.assertIn("T", snapshot.started_at)
def test_process_uptime_helper_matches_shape(self):
started_at, uptime = process_uptime()
self.assertIn("T", started_at)
self.assertGreaterEqual(uptime, 0.0)
def test_version_reports_python_and_schema_version(self):
probes = (
DependencyProbe(
name="control_plane_db",
kind="sqlite",
status=STATUS_OK,
detail="ok",
required=True,
latency_ms=1.0,
metadata={"schema_version": control_plane_db.SCHEMA_VERSION},
),
_probe("repository", STATUS_OK, kind="git"),
)
snapshot = load_system_health(probes=probes, daemon_head="abc123")
self.assertEqual(
snapshot.version.control_plane_schema_version,
control_plane_db.SCHEMA_VERSION,
)
self.assertTrue(snapshot.version.python_version)
def test_version_known_flag_false_when_sha_unavailable(self):
with mock.patch("webui.system_health._git", return_value=None):
snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc")
self.assertIsNone(snapshot.version.git_sha)
self.assertFalse(snapshot.version.known)
class TestStaleRuntime(unittest.TestCase):
"""AC3 — stale runtime reflected without a false mutation-safe claim."""
def test_matching_commits_are_mutation_safe(self):
assessment = assess_stale_runtime(
Path("/tmp"),
daemon_head="aaa",
git_reader=lambda *args: "aaa",
)
self.assertFalse(assessment.stale)
self.assertTrue(assessment.determinable)
self.assertTrue(assessment.mutation_safe)
def test_diverged_commits_are_stale_and_not_mutation_safe(self):
reads = {"HEAD": "aaa", "@{upstream}": "bbb"}
assessment = assess_stale_runtime(
Path("/tmp"),
daemon_head="aaa",
git_reader=lambda *args: reads.get(args[-1]),
)
self.assertTrue(assessment.stale)
self.assertFalse(assessment.mutation_safe)
self.assertTrue(assessment.reasons)
def test_unknown_remote_is_not_mutation_safe(self):
"""Indeterminate must never read as safe."""
reads = {"HEAD": "aaa", "@{upstream}": None}
assessment = assess_stale_runtime(
Path("/tmp"),
daemon_head="aaa",
git_reader=lambda *args: reads.get(args[-1]),
)
self.assertFalse(assessment.determinable)
self.assertFalse(assessment.mutation_safe)
self.assertFalse(assessment.stale)
self.assertTrue(
any("indeterminate" in reason for reason in assessment.reasons)
)
def test_unobservable_daemon_head_is_disclosed(self):
assessment = assess_stale_runtime(
Path("/tmp"),
git_reader=lambda *args: "aaa",
)
self.assertTrue(
any("not observable" in reason for reason in assessment.reasons)
)
def test_stale_runtime_degrades_overall_status(self):
reads = {"HEAD": "aaa", "@{upstream}": "bbb"}
# Pinned rather than inherited: this path uses the default git reader,
# so the assertion must hold whether or not the suite runs offline.
with mock.patch.dict(os.environ, {"WEBUI_TEST_OFFLINE": ""}), mock.patch(
"webui.system_health._git",
side_effect=lambda repo, *args: reads.get(args[-1]),
):
snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="aaa")
self.assertTrue(snapshot.stale_runtime.stale)
self.assertFalse(snapshot.stale_runtime.mutation_safe)
self.assertEqual(snapshot.status, STATUS_DEGRADED)
class TestControlPlaneDbProbe(unittest.TestCase):
"""The required local dependency, probed read-only."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.db_path = str(Path(self.tmp.name) / "control-plane.db")
def _build_db(self, schema_version):
conn = sqlite3.connect(self.db_path)
conn.execute("CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT)")
conn.execute("CREATE TABLE leases (lease_id TEXT PRIMARY KEY, status TEXT)")
conn.execute(
"INSERT INTO schema_meta(key, value) VALUES ('schema_version', ?)",
(str(schema_version),),
)
conn.execute("INSERT INTO leases(lease_id, status) VALUES ('l1', 'active')")
conn.commit()
conn.close()
def test_missing_database_is_down(self):
probe = probe_control_plane_db(str(Path(self.tmp.name) / "absent.db"))
self.assertEqual(probe.status, STATUS_DOWN)
self.assertTrue(probe.required)
self.assertIsNotNone(probe.latency_ms)
def test_matching_schema_is_ok(self):
self._build_db(control_plane_db.SCHEMA_VERSION)
probe = probe_control_plane_db(self.db_path)
self.assertEqual(probe.status, STATUS_OK)
self.assertEqual(
probe.metadata["schema_version"], control_plane_db.SCHEMA_VERSION
)
self.assertEqual(probe.metadata["active_leases"], 1)
def test_mismatched_schema_is_degraded(self):
self._build_db(control_plane_db.SCHEMA_VERSION + 99)
probe = probe_control_plane_db(self.db_path)
self.assertEqual(probe.status, STATUS_DEGRADED)
def test_probe_does_not_create_a_database(self):
"""A health check must never initialise the substrate it inspects."""
absent = str(Path(self.tmp.name) / "never-created.db")
probe_control_plane_db(absent)
self.assertFalse(Path(absent).exists())
def test_unreadable_database_is_down_not_raised(self):
Path(self.db_path).write_text("this is not a sqlite database")
probe = probe_control_plane_db(self.db_path)
self.assertEqual(probe.status, STATUS_DOWN)
class TestRedaction(unittest.TestCase):
"""AC4 — redaction. No credential-shaped text crosses the boundary."""
def test_redacts_token_assignment(self):
cleaned = redact("failed with token=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345")
self.assertNotIn("ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345", cleaned)
self.assertIn("[redacted]", cleaned)
def test_redacts_authorization_header_text(self):
cleaned = redact("Authorization: Bearer abcdefghijklmnopqrstuvwxyz123456")
self.assertNotIn("abcdefghijklmnopqrstuvwxyz123456", cleaned)
def test_redacts_long_opaque_strings(self):
cleaned = redact("value 0123456789abcdef0123456789abcdef here")
self.assertNotIn("0123456789abcdef0123456789abcdef", cleaned)
def test_url_userinfo_and_query_are_stripped(self):
cleaned = redact_url("https://user:[email protected]/api/v1?token=xyz")
self.assertNotIn("secretpass", cleaned)
self.assertNotIn("token=xyz", cleaned)
self.assertEqual(cleaned, "https://gitea.example.com/api/v1")
def test_url_inside_free_text_is_redacted(self):
cleaned = redact("GET https://u:[email protected]/x?token=abc failed")
self.assertNotIn("u:p@", cleaned)
self.assertNotIn("token=abc", cleaned)
def test_gitea_probe_failure_detail_is_redacted(self):
boom = RuntimeError(
"connection refused for https://user:[email protected]/api/v1/version"
)
with mock.patch("webui.system_health.get_auth_header", return_value="token x"), \
mock.patch("webui.system_health.api_request", side_effect=boom):
probe = probe_gitea("gitea.example.com")
self.assertEqual(probe.status, STATUS_DOWN)
self.assertNotIn("hunter2", probe.detail)
self.assertEqual(scan_text_for_client_secrets(probe.detail), [])
def test_credential_guard_refusal_is_a_status_not_a_crash(self):
with mock.patch(
"webui.system_health.get_auth_header",
side_effect=RuntimeError("daemon guard refused"),
):
probe = probe_gitea("gitea.example.com")
self.assertEqual(probe.status, STATUS_DEGRADED)
self.assertFalse(probe.required)
class TestNamespaceSummaries(unittest.TestCase):
"""A web process cannot prove IDE namespace health, and must not claim to."""
def test_every_namespace_reports_unproven(self):
rows = namespace_summaries()
self.assertTrue(rows)
for row in rows:
with self.subTest(namespace=row["namespace"]):
self.assertEqual(row["status"], "unproven")
self.assertFalse(row["ide_namespace_proven"])
self.assertIn("client_namespace", row["reason"])
class TestSystemHealthRoutes(CleanParityMixin, unittest.TestCase):
"""The HTTP surface: versioned path, status codes, read-only guard."""
def setUp(self):
super().setUp()
clear_probe_cache()
self.addCleanup(clear_probe_cache)
self.client = TestClient(create_app())
def _patch_snapshot(self, probes, daemon_head="abc123"):
snapshot = load_system_health(probes=probes, daemon_head=daemon_head)
patcher = mock.patch(
"webui.app.load_system_health",
return_value=snapshot,
)
patcher.start()
self.addCleanup(patcher.stop)
return snapshot
def test_versioned_route_is_registered(self):
self.assertEqual(API_PATH, "/api/v1/system/health")
self._patch_snapshot(_ALL_HEALTHY)
response = self.client.get(API_PATH)
self.assertEqual(response.status_code, 200)
def test_healthy_payload_shape(self):
self._patch_snapshot(_ALL_HEALTHY)
data = self.client.get(API_PATH).json()
self.assertEqual(data["status"], STATUS_OK)
self.assertTrue(data["readiness"]["ready"])
self.assertTrue(data["readiness"]["complete"])
self.assertEqual(data["api"], API_PATH)
self.assertEqual(len(data["dependencies"]), 3)
for key in ("version", "process", "stale_runtime", "mcp_namespaces"):
self.assertIn(key, data)
self.assertIn("uptime_seconds", data["process"])
self.assertIn("mutation_safe", data["stale_runtime"])
def test_degraded_dependency_returns_503(self):
probes = (
_probe("control_plane_db", STATUS_DOWN, detail="missing", kind="sqlite"),
_probe("repository", STATUS_OK, kind="git"),
)
self._patch_snapshot(probes)
response = self.client.get(API_PATH)
self.assertEqual(response.status_code, 503)
data = response.json()
self.assertFalse(data["readiness"]["ready"])
self.assertTrue(data["readiness"]["reasons"])
def test_dependency_entries_expose_status_and_latency(self):
self._patch_snapshot(_ALL_HEALTHY)
data = self.client.get(API_PATH).json()
names = {entry["name"] for entry in data["dependencies"]}
self.assertEqual(names, {"control_plane_db", "repository", "gitea"})
for entry in data["dependencies"]:
with self.subTest(dependency=entry["name"]):
self.assertIn("status", entry)
self.assertIn("required", entry)
self.assertIn("latency_ms", entry)
def test_response_body_carries_no_client_secrets(self):
self._patch_snapshot(_ALL_HEALTHY)
body = self.client.get(API_PATH).text
self.assertEqual(scan_text_for_client_secrets(body), [])
def test_deep_flag_is_forwarded(self):
snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc")
with mock.patch(
"webui.app.load_system_health", return_value=snapshot
) as loader:
self.client.get(f"{API_PATH}?deep=1")
loader.assert_called_once_with(deep=True)
def test_shallow_is_the_default(self):
snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc")
with mock.patch(
"webui.app.load_system_health", return_value=snapshot
) as loader:
self.client.get(API_PATH)
loader.assert_called_once_with(deep=False)
def test_route_rejects_mutation_methods(self):
for method in ("POST", "PUT", "PATCH", "DELETE"):
with self.subTest(method=method):
response = self.client.request(method, API_PATH)
self.assertEqual(response.status_code, 405)
self.assertEqual(response.json()["error"], "read-only-mvp")
def test_default_shallow_call_skips_the_network_probe(self):
"""The expensive probe must not run unless it was asked for."""
with mock.patch("webui.system_health.probe_gitea") as probe:
snapshot = load_system_health(deep=False)
probe.assert_not_called()
gitea = next(p for p in snapshot.dependencies if p.name == "gitea")
self.assertEqual(gitea.status, STATUS_SKIPPED)
class TestHealthRouteBackwardCompatibility(unittest.TestCase):
"""`/health` is expanded additively; MVP consumers must keep working."""
def setUp(self):
self.client = TestClient(create_app())
def test_mvp_keys_are_unchanged(self):
data = self.client.get("/health").json()
self.assertEqual(data["status"], "ok")
self.assertEqual(data["service"], "mcp-control-plane-webui")
self.assertEqual(data["mode"], "read-only-mvp")
self.assertIn("timestamp", data)
self.assertEqual(data["deployment"]["mode"], "internal-operator-console")
def test_health_points_at_the_versioned_api(self):
data = self.client.get("/health").json()
self.assertEqual(data["system_health_api"], API_PATH)
self.assertIn("uptime_seconds", data)
self.assertIn("started_at", data)
def test_health_runs_no_dependency_probe(self):
"""Liveness must stay cheap: no probe, no snapshot assembly."""
with mock.patch("webui.app.load_system_health") as loader:
response = self.client.get("/health")
self.assertEqual(response.status_code, 200)
loader.assert_not_called()
class TestSnapshotSerialisation(CleanParityMixin, unittest.TestCase):
def test_snapshot_dict_is_json_serialisable(self):
snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc123")
encoded = json.dumps(snapshot_to_dict(snapshot))
self.assertIn("readiness", encoded)
if __name__ == "__main__":
unittest.main()
+34
View File
@@ -29,6 +29,12 @@ from webui.worktree_scanner import load_hygiene_snapshot, snapshot_to_dict as wo
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"})
@@ -62,16 +68,43 @@ async def home(_request: Request) -> HTMLResponse:
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)))
@@ -263,6 +296,7 @@ def create_app(*, bind_host: str | None = None) -> Starlette:
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"]),
+682
View File
@@ -0,0 +1,682 @@
"""Read-only system-health model for the operator console API (#634).
`/health` answers liveness only. Operators automating readiness checks need a
structured view of *why* the control plane is or is not usable: which
dependencies answered, how long they took, what version of the code is running,
and whether the runtime is stale relative to its remote.
Three rules shape this module.
* **Read-only.** Every probe opens its subject read-only. The control-plane
database is opened through a ``mode=ro`` URI so a health check can never
create or migrate a schema, and no probe writes, restarts, or reloads
anything — restart controls are Phase 2, and #630 forbids process-kill
recovery outright.
* **Fail-soft.** A dependency that is unreachable is a *status*, not an
exception. Probes catch their own failures and report them as a degraded or
down entry carrying a reason.
* **Never claim more than was proven.** Readiness is derived only from probes
that actually ran, ``mutation_safe`` stays false unless the parity commits are
known and equal, and an MCP namespace is reported unproven because a web
process cannot exercise the IDE-managed client path (#543).
"""
from __future__ import annotations
import os
import re
import sqlite3
import subprocess
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
from urllib.parse import urlsplit, urlunsplit
import control_plane_db
import mcp_namespace_health
from gitea_auth import api_request, get_auth_header, gitea_url
from webui.project_registry import load_registry
SERVICE_NAME = "mcp-control-plane-webui"
API_PATH = "/api/v1/system/health"
STATUS_OK = "ok"
STATUS_DEGRADED = "degraded"
STATUS_DOWN = "down"
STATUS_SKIPPED = "skipped"
STATUS_UNPROVEN = "unproven"
# Statuses that count as a healthy answer from a probe.
_HEALTHY_STATUSES = frozenset({STATUS_OK})
# Statuses meaning "this probe did not run", as opposed to "it ran and failed".
_NOT_RUN_STATUSES = frozenset({STATUS_SKIPPED})
_DEEP_PROBE_TTL_ENV = "WEBUI_HEALTH_PROBE_TTL_SECONDS"
_DEFAULT_DEEP_PROBE_TTL = 15.0
_GITEA_PROBE_TIMEOUT_SECONDS = 5.0
_OFFLINE_ENV = "WEBUI_TEST_OFFLINE"
# Credential-shaped material that must never reach the browser, mirroring the
# forbidden client patterns in webui/deployment_boundary.py.
_SECRET_RE = re.compile(
r"(?i)\b(token|password|passwd|secret|authorization|bearer)\b\s*[:=]?\s*\S+"
)
_LONG_OPAQUE_RE = re.compile(r"\b[A-Za-z0-9_\-]{32,}\b")
# Captured once at import so uptime measures this process, not the request.
_STARTED_AT = datetime.now(timezone.utc)
_STARTED_MONOTONIC = time.monotonic()
# TTL cache for the expensive (network) probe only.
_deep_cache: dict[str, tuple[float, "DependencyProbe"]] = {}
@dataclass(frozen=True)
class DependencyProbe:
"""One dependency check, fail-soft, with its own latency."""
name: str
kind: str
status: str
detail: str
required: bool
latency_ms: float | None = None
metadata: dict[str, Any] | None = None
@property
def healthy(self) -> bool:
return self.status in _HEALTHY_STATUSES
@property
def ran(self) -> bool:
return self.status not in _NOT_RUN_STATUSES
@dataclass(frozen=True)
class VersionInfo:
git_sha: str | None
git_describe: str | None
control_plane_schema_version: int | None
python_version: str
known: bool
@dataclass(frozen=True)
class StaleRuntime:
"""Parity between the running code, the checkout, and the remote.
``mutation_safe`` is deliberately conservative: unknown is not safe.
"""
daemon_head: str | None
checkout_head: str | None
remote_head: str | None
stale: bool
determinable: bool
mutation_safe: bool
reasons: tuple[str, ...]
@dataclass(frozen=True)
class SystemHealthSnapshot:
status: str
ready: bool
readiness_complete: bool
readiness_reasons: tuple[str, ...]
service: str
mode: str
version: VersionInfo
started_at: str
uptime_seconds: float
timestamp: str
deep_probes_requested: bool
dependencies: tuple[DependencyProbe, ...]
mcp_namespaces: tuple[dict[str, Any], ...]
stale_runtime: StaleRuntime
probe_errors: tuple[str, ...] = ()
def process_uptime() -> tuple[str, float]:
"""Process start timestamp and uptime — in-memory, safe for `/health`."""
return _STARTED_AT.isoformat(), round(time.monotonic() - _STARTED_MONOTONIC, 3)
def _offline() -> bool:
return (os.environ.get(_OFFLINE_ENV) or "").strip().lower() in {"1", "true", "yes"}
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 _deep_probe_ttl() -> float:
raw = (os.environ.get(_DEEP_PROBE_TTL_ENV) or "").strip()
if not raw:
return _DEFAULT_DEEP_PROBE_TTL
try:
value = float(raw)
except ValueError:
return _DEFAULT_DEEP_PROBE_TTL
return value if value >= 0 else _DEFAULT_DEEP_PROBE_TTL
def redact(text: str) -> str:
"""Strip credential-shaped material from operator-visible probe text.
Probe details carry exception strings, and an exception raised by an HTTP
client can quote the request that failed. Redaction happens here, at the
boundary where those strings become part of a browser-bound payload.
"""
if not text:
return ""
cleaned = _redact_urls(text)
cleaned = _SECRET_RE.sub(lambda m: f"{m.group(1)}=[redacted]", cleaned)
return _LONG_OPAQUE_RE.sub("[redacted]", cleaned)
def _redact_urls(text: str) -> str:
return re.sub(r"https?://\S+", lambda m: redact_url(m.group(0)), text)
def redact_url(url: str) -> str:
"""Reduce a URL to scheme://host/path — no userinfo, no query, no fragment."""
try:
parts = urlsplit(url)
except ValueError:
return "[redacted-url]"
if not parts.scheme or not parts.hostname:
return "[redacted-url]"
netloc = parts.hostname
if parts.port:
netloc = f"{netloc}:{parts.port}"
return urlunsplit((parts.scheme, netloc, parts.path, "", ""))
def _git(repo: Path, *args: str) -> str | None:
try:
completed = subprocess.run(
["git", "-C", str(repo), *args],
capture_output=True,
text=True,
check=False,
timeout=10,
)
except (OSError, subprocess.SubprocessError):
return None
if completed.returncode != 0:
return None
return (completed.stdout or "").strip() or None
def _load_version(repo: Path, *, schema_version: int | None) -> VersionInfo:
import platform
git_sha = None if _offline() else _git(repo, "rev-parse", "HEAD")
describe = None if _offline() else _git(repo, "describe", "--tags", "--always")
return VersionInfo(
git_sha=git_sha,
git_describe=describe,
control_plane_schema_version=schema_version,
python_version=platform.python_version(),
known=bool(git_sha),
)
# ---------------------------------------------------------------------------
# Dependency probes
# ---------------------------------------------------------------------------
def _elapsed_ms(started: float) -> float:
return round((time.monotonic() - started) * 1000, 3)
def probe_control_plane_db(db_path: str | None = None) -> DependencyProbe:
"""Read-only reachability check for the control-plane SQLite substrate.
Opened through a ``mode=ro`` URI on purpose: ``ControlPlaneDB.__init__``
creates directories and runs schema migrations, which a health check must
never do.
"""
path = (db_path or control_plane_db.default_db_path()).strip()
started = time.monotonic()
metadata: dict[str, Any] = {"path": path}
def _result(status: str, detail: str) -> DependencyProbe:
return DependencyProbe(
name="control_plane_db",
kind="sqlite",
status=status,
detail=detail,
required=True,
latency_ms=_elapsed_ms(started),
metadata=metadata,
)
if not path or not os.path.exists(path):
return _result(STATUS_DOWN, "control-plane database file does not exist yet")
try:
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=5)
try:
row = conn.execute(
"SELECT value FROM schema_meta WHERE key = 'schema_version'"
).fetchone()
leases = conn.execute(
"SELECT COUNT(*) FROM leases WHERE status = 'active'"
).fetchone()
finally:
conn.close()
except sqlite3.Error as exc:
return _result(STATUS_DOWN, redact(f"control-plane database unreadable: {exc}"))
schema_version = int(row[0]) if row and str(row[0]).isdigit() else None
metadata["schema_version"] = schema_version
metadata["active_leases"] = int(leases[0]) if leases else None
if schema_version is None:
return _result(
STATUS_DEGRADED, "control-plane database has no recorded schema version"
)
if schema_version != control_plane_db.SCHEMA_VERSION:
return _result(
STATUS_DEGRADED,
f"control-plane schema version {schema_version} does not match the "
f"version this code expects ({control_plane_db.SCHEMA_VERSION})",
)
return _result(STATUS_OK, f"schema v{schema_version} readable")
def probe_repository(repo: Path) -> DependencyProbe:
"""Local checkout reachability — required, cheap, no network."""
started = time.monotonic()
metadata: dict[str, Any] = {"repo_root": str(repo)}
if _offline():
return DependencyProbe(
name="repository",
kind="git",
status=STATUS_SKIPPED,
detail=f"{_OFFLINE_ENV} is set; git probe skipped",
required=True,
latency_ms=_elapsed_ms(started),
metadata=metadata,
)
head = _git(repo, "rev-parse", "HEAD")
if not head:
return DependencyProbe(
name="repository",
kind="git",
status=STATUS_DOWN,
detail=f"HEAD could not be read at {repo}",
required=True,
latency_ms=_elapsed_ms(started),
metadata=metadata,
)
branch = _git(repo, "rev-parse", "--abbrev-ref", "HEAD")
metadata["head"] = head
metadata["branch"] = branch
return DependencyProbe(
name="repository",
kind="git",
status=STATUS_OK,
detail=f"checkout readable at {branch or 'detached HEAD'}",
required=True,
latency_ms=_elapsed_ms(started),
metadata=metadata,
)
def probe_gitea(host: str) -> DependencyProbe:
"""Live Gitea reachability. Expensive (network), so opt-in via ``deep``.
Optional by design: the console stays useful for local inventory when the
remote is unreachable, so a failure here degrades status without claiming
the process itself is unready.
"""
started = time.monotonic()
metadata: dict[str, Any] = {"host": host}
def _failure(status: str, detail: str) -> DependencyProbe:
return DependencyProbe(
name="gitea",
kind="http",
status=status,
detail=detail,
required=False,
latency_ms=_elapsed_ms(started),
metadata=metadata,
)
if not host:
return _failure(STATUS_DEGRADED, "no Gitea host is configured in the registry")
try:
auth = get_auth_header(host)
except Exception as exc: # noqa: BLE001 — credential guards are a status here
return _failure(STATUS_DEGRADED, redact(f"credential lookup refused: {exc}"))
if not auth:
return _failure(STATUS_DEGRADED, f"no credentials available for {host}")
url = gitea_url(host, "/api/v1/version")
metadata["endpoint"] = redact_url(url)
try:
data = api_request("GET", url, auth, timeout=_GITEA_PROBE_TIMEOUT_SECONDS)
except Exception as exc: # noqa: BLE001 — a down dependency is a status
return _failure(STATUS_DOWN, redact(f"Gitea probe failed: {exc}"))
if isinstance(data, dict) and data.get("version"):
metadata["gitea_version"] = str(data["version"])
return DependencyProbe(
name="gitea",
kind="http",
status=STATUS_OK,
detail=f"{host} reachable",
required=False,
latency_ms=_elapsed_ms(started),
metadata=metadata,
)
def _skipped_gitea(host: str) -> DependencyProbe:
return DependencyProbe(
name="gitea",
kind="http",
status=STATUS_SKIPPED,
detail="network probe not requested; call with ?deep=1 to run it",
required=False,
latency_ms=None,
metadata={"host": host},
)
def namespace_summaries() -> tuple[dict[str, Any], ...]:
"""Declared MCP namespaces, each honestly reported as unproven.
The web process runs outside the IDE-managed MCP client, so it cannot
invoke a namespace tool. Per #543 only a ``client_namespace`` probe proves
that path, and inventing a healthy verdict here is exactly the false claim
the mutation gates exist to prevent.
"""
rows: list[dict[str, Any]] = []
for namespace, required_tool in sorted(
mcp_namespace_health.REQUIRED_NAMESPACE_TOOLS.items()
):
classification = mcp_namespace_health.classify_namespace_probe(
namespace,
required_tool=required_tool,
probe_result=None,
probe_source=mcp_namespace_health.PROBE_SOURCE_UNKNOWN,
)
rows.append(
{
"namespace": namespace,
"required_tool": required_tool,
"status": STATUS_UNPROVEN,
"ide_namespace_proven": bool(classification.get("ide_namespace_proven")),
"reason": (
"the web console cannot invoke the IDE-managed MCP client; "
"namespace health must be proven with a client_namespace "
"probe (#543)"
),
"error_type": classification.get("error_type"),
}
)
return tuple(rows)
def assess_stale_runtime(
repo: Path,
*,
daemon_head: str | None = None,
git_reader: Callable[..., str | None] | None = None,
) -> StaleRuntime:
"""Three-way parity view: running code, local checkout, remote-tracking ref.
``mutation_safe`` requires all three to be known and equal. Anything less —
including "the remote ref was never fetched" — is reported as not safe with
a reason, so an operator never reads an unproven green.
"""
reader = git_reader or (lambda *args: _git(repo, *args))
reasons: list[str] = []
# The offline switch suppresses real subprocess calls; an explicitly
# injected reader is already a substitute for them and is always used.
offline = _offline() and git_reader is None
checkout_head = None if offline else reader("rev-parse", "HEAD")
remote_head = None if offline else reader("rev-parse", "@{upstream}")
if offline:
reasons.append(f"{_OFFLINE_ENV} is set; parity commits were not read")
else:
if checkout_head is None:
reasons.append("local checkout HEAD could not be read")
if remote_head is None:
reasons.append(
"no remote-tracking commit is known for the current branch; "
"remote staleness is indeterminate (no fetch is performed here)"
)
effective_daemon = daemon_head if daemon_head is not None else checkout_head
if daemon_head is None:
reasons.append(
"the running MCP daemon's startup commit is not observable from the "
"web process; the checkout commit is reported in its place"
)
determinable = bool(checkout_head and remote_head and effective_daemon)
stale = bool(
determinable and len({checkout_head, remote_head, effective_daemon}) > 1
)
if stale:
reasons.append(
"runtime, checkout, and remote commits disagree; restart the MCP "
"server after updating the checkout before trusting capability gates"
)
return StaleRuntime(
daemon_head=effective_daemon,
checkout_head=checkout_head,
remote_head=remote_head,
stale=stale,
determinable=determinable,
mutation_safe=bool(determinable and not stale),
reasons=tuple(reasons),
)
# ---------------------------------------------------------------------------
# Snapshot assembly
# ---------------------------------------------------------------------------
def _default_host() -> str:
registry = load_registry()
if not registry.projects:
return ""
raw = registry.projects[0].remote_host
parts = urlsplit(raw.strip())
return parts.netloc or raw.strip().rstrip("/")
def _aggregate(
probes: tuple[DependencyProbe, ...],
) -> tuple[str, bool, bool, tuple[str, ...]]:
"""Fold probe results into overall status and readiness.
Required probes drive readiness; optional probes can only degrade status.
A probe that did not run leaves readiness incomplete rather than passing.
"""
reasons: list[str] = []
required = [probe for probe in probes if probe.required]
unrun_required = [probe for probe in required if not probe.ran]
failed_required = [probe for probe in required if probe.ran and not probe.healthy]
failed_optional = [
probe
for probe in probes
if not probe.required and probe.ran and not probe.healthy
]
for probe in unrun_required:
reasons.append(
f"required dependency '{probe.name}' was not probed: {probe.detail}"
)
for probe in failed_required:
reasons.append(
f"required dependency '{probe.name}' is {probe.status}: {probe.detail}"
)
for probe in failed_optional:
reasons.append(
f"optional dependency '{probe.name}' is {probe.status}: {probe.detail}"
)
readiness_complete = not unrun_required
ready = readiness_complete and not failed_required
if any(probe.status == STATUS_DOWN for probe in failed_required):
status = STATUS_DOWN
elif failed_required or failed_optional or unrun_required:
status = STATUS_DEGRADED
else:
status = STATUS_OK
return status, ready, readiness_complete, tuple(reasons)
def load_system_health(
*,
deep: bool = False,
host: str | None = None,
probes: tuple[DependencyProbe, ...] | None = None,
daemon_head: str | None = None,
use_cache: bool = True,
) -> SystemHealthSnapshot:
"""Assemble the read-only system-health snapshot.
``deep=True`` adds the network probe against Gitea; its result is cached for
a short TTL so repeated dashboard polls do not amplify into remote load.
"""
repo = _repo_root()
probe_errors: list[str] = []
if probes is None:
collected: list[DependencyProbe] = []
for probe_fn in (
lambda: probe_control_plane_db(),
lambda: probe_repository(repo),
):
try:
collected.append(probe_fn())
except Exception as exc: # noqa: BLE001 — a probe must not 500 the API
probe_errors.append(redact(f"probe raised: {exc}"))
resolved_host = host if host is not None else _default_host()
if deep and not _offline():
collected.append(_cached_gitea_probe(resolved_host, use_cache=use_cache))
else:
collected.append(_skipped_gitea(resolved_host))
probes = tuple(collected)
status, ready, readiness_complete, reasons = _aggregate(probes)
stale = assess_stale_runtime(repo, daemon_head=daemon_head)
if stale.stale:
if status == STATUS_OK:
status = STATUS_DEGRADED
reasons = reasons + (
"runtime is stale relative to its remote-tracking commit",
)
db_probe = next((p for p in probes if p.name == "control_plane_db"), None)
schema_version = None
if db_probe and db_probe.metadata:
schema_version = db_probe.metadata.get("schema_version")
return SystemHealthSnapshot(
status=status,
ready=ready,
readiness_complete=readiness_complete,
readiness_reasons=reasons,
service=SERVICE_NAME,
mode="read-only",
version=_load_version(repo, schema_version=schema_version),
started_at=_STARTED_AT.isoformat(),
uptime_seconds=round(time.monotonic() - _STARTED_MONOTONIC, 3),
timestamp=datetime.now(timezone.utc).isoformat(),
deep_probes_requested=deep,
dependencies=probes,
mcp_namespaces=namespace_summaries(),
stale_runtime=stale,
probe_errors=tuple(probe_errors),
)
def _cached_gitea_probe(host: str, *, use_cache: bool = True) -> DependencyProbe:
ttl = _deep_probe_ttl()
now = time.monotonic()
if use_cache and ttl > 0:
cached = _deep_cache.get(host)
if cached and (now - cached[0]) < ttl:
return cached[1]
probe = probe_gitea(host)
if use_cache and ttl > 0:
_deep_cache[host] = (now, probe)
return probe
def clear_probe_cache() -> None:
"""Drop cached deep-probe results (tests and operator-forced refresh)."""
_deep_cache.clear()
def probe_to_dict(probe: DependencyProbe) -> dict[str, Any]:
return {
"name": probe.name,
"kind": probe.kind,
"status": probe.status,
"detail": probe.detail,
"required": probe.required,
"healthy": probe.healthy,
"latency_ms": probe.latency_ms,
"metadata": dict(probe.metadata or {}),
}
def snapshot_to_dict(snapshot: SystemHealthSnapshot) -> dict[str, Any]:
return {
"status": snapshot.status,
"service": snapshot.service,
"mode": snapshot.mode,
"api": API_PATH,
"timestamp": snapshot.timestamp,
"readiness": {
"ready": snapshot.ready,
"complete": snapshot.readiness_complete,
"reasons": list(snapshot.readiness_reasons),
},
"version": {
"git_sha": snapshot.version.git_sha,
"git_describe": snapshot.version.git_describe,
"control_plane_schema_version": (
snapshot.version.control_plane_schema_version
),
"python_version": snapshot.version.python_version,
"known": snapshot.version.known,
},
"process": {
"started_at": snapshot.started_at,
"uptime_seconds": snapshot.uptime_seconds,
},
"deep_probes_requested": snapshot.deep_probes_requested,
"dependencies": [probe_to_dict(probe) for probe in snapshot.dependencies],
"mcp_namespaces": [dict(row) for row in snapshot.mcp_namespaces],
"stale_runtime": {
"daemon_head": snapshot.stale_runtime.daemon_head,
"checkout_head": snapshot.stale_runtime.checkout_head,
"remote_head": snapshot.stale_runtime.remote_head,
"stale": snapshot.stale_runtime.stale,
"determinable": snapshot.stale_runtime.determinable,
"mutation_safe": snapshot.stale_runtime.mutation_safe,
"reasons": list(snapshot.stale_runtime.reasons),
},
"probe_errors": list(snapshot.probe_errors),
}