Compare commits

..
Author SHA1 Message Date
sysadminandClaude Opus 4.8 b2f6e9a6dc feat(webui): versioned project registry API (Closes #635)
Evolve the MVP project registry (#427) into a versioned, fail-closed
project registry API for the console (Phase 1, read-only).

- Add schema version 2 with project `status`, per-step onboarding
  `state`/`required`, optional redacted `last_seen_health`, and
  `remote_name`. Version 1 files stay loadable and are normalized with
  explicit defaults.
- Serve `/api/v1/projects` and `/api/v1/projects/{project_id}` with API
  provenance (`api_version`, `schema_version`, `source`). `/api/projects`
  is retained as an unversioned Phase 1 alias.
- Replace bare `ValueError` with `RegistryError`, carrying an operator
  `remediation` and `field_path`; invalid registries fail closed as a
  500 JSON payload or a dedicated HTML error page instead of a traceback.
- Reject credential-shaped keys before any DTO is built, reusing
  `registry_safety.is_forbidden_key` as the single source of truth
  shared with the worker registry (#798).
- Render HTML views from `project_to_dict`, so the console and the JSON
  API cannot disagree about status or onboarding progress.
- Document the contract in docs/webui-project-registry-api.md.

Tests: registry load/validate (valid, missing project, schema
validation, credential rejection) and API route coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-22 16:12:07 -04:00
9 changed files with 1213 additions and 1390 deletions
+9 -83
View File
@@ -43,18 +43,23 @@ for the console architecture: layer and authority boundaries, the redaction
boundary, `/api/v1/...` versioning, the target page map, and the phase gates
that govern when a write path may open (#632, epic #631).
See [webui-project-registry-api.md](webui-project-registry-api.md) for the
versioned project registry contract: registry schema versions 1 and 2, project
status, onboarding checklist state, and the fail-closed error payloads (#635).
## Routes (MVP)
| Path | Description |
|------|-------------|
| `/` | Home / operator overview |
| `/health` | JSON liveness (`status`, `service`, `mode`, `timestamp`, `uptime_seconds`) |
| `/api/v1/system/health` | Structured read-only system health (#634) |
| `/health` | JSON liveness (`status`, `service`, `mode`, `timestamp`) |
| `/queue` | Live PR and issue queue dashboard (#429) |
| `/api/queue` | JSON queue export with pagination metadata |
| `/projects` | Project registry list (#427) |
| `/projects` | Project registry list with status and onboarding progress (#427, #635) |
| `/projects/{id}` | Project detail + onboarding checklist |
| `/api/projects` | JSON registry export |
| `/api/v1/projects` | Versioned JSON registry export (#635) |
| `/api/v1/projects/{id}` | Versioned JSON project detail (#635) |
| `/api/projects` | JSON registry export — unversioned Phase 1 alias of `/api/v1/projects` |
| `/prompts` | Prompt library with per-prompt copy buttons (#428) |
| `/api/prompts` | JSON prompt export with workflow hashes |
| `/runtime` | MCP runtime health and stale detection (#430) |
@@ -73,85 +78,6 @@ 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
+213
View File
@@ -0,0 +1,213 @@
# Project registry API (#635)
Phase 1 of the [console architecture ADR](architecture/webui-control-plane-console-architecture-adr.md)
gives the project registry a versioned, read-only API. This document is the
field-by-field contract for that API and for the registry file behind it.
Everything here is **read-only**. The console never writes the registry; an
operator edits the JSON file, and an invalid file fails closed rather than
rendering a partial inventory.
## Routes
| Route | Method | Description |
|-------|--------|-------------|
| `/api/v1/projects` | GET | Versioned registry export: all projects, with provenance |
| `/api/v1/projects/{project_id}` | GET | Single project; `404` with `project_not_found` when unknown |
| `/api/projects` | GET | Unversioned MVP alias (#427), retained for all of Phase 1 |
| `/projects` | GET | HTML list — status and onboarding progress per project |
| `/projects/{project_id}` | GET | HTML detail — identity, profiles, paths, checklist |
Per ADR section 6 the unversioned alias may be retired no earlier than Phase 2,
and only after this document and `webui-local-dev.md` record the swap. The alias
returns the same payload as `/api/v1/projects`, including the legacy `version`
and `source_path` keys #427 consumers already read.
The HTML views render from the same DTO the JSON routes serialize
(`project_to_dict`), so the console and the API cannot disagree about a
project's status or onboarding progress.
## Registry file
Default location: `webui/data/projects.registry.json`. Override with the
`WEBUI_PROJECT_REGISTRY` environment variable.
Schema versions: **1** and **2** are accepted; **2** is current. A version 1
file loads unchanged and is normalized with the documented defaults, so an
existing operator registry keeps working without edits.
### Root
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `version` | int | yes | `1` or `2`. Anything else fails closed |
| `projects` | array | yes | Must be non-empty |
### Project
| Field | Type | Required | Default | Notes |
|-------|------|----------|---------|-------|
| `id` | string | yes | — | Stable registry id used in URLs |
| `repo_name` | string | yes | — | Gitea repository name |
| `gitea_owner` | string | yes | — | Owning org or user |
| `remote_host` | string | yes | — | Instance base URL, no credentials |
| `remote_name` | string | no | `null` | Logical remote label, e.g. `prgs` (v2) |
| `default_branch` | string | yes | — | Stable branch name |
| `local_checkout_path` | string | yes | — | Control checkout path |
| `status` | string | no | `active` | `active`, `onboarding`, `paused`, `archived` (v2) |
| `profiles` | object | yes | — | Must map `author`, `reviewer`, `reconciler` |
| `workflow_paths` | object | yes | — | Non-empty; label to repo-relative path |
| `schema_paths` | object | no | `{}` | Label to repo-relative path |
| `onboarding_checklist` | array | no | `[]` | See below |
| `last_seen_health` | object | no | `null` | Redacted health only (v2) |
### Onboarding step
| Field | Type | Required | Default | Notes |
|-------|------|----------|---------|-------|
| `id` | string | yes | — | Stable step id |
| `title` | string | yes | — | Short operator-facing label |
| `description` | string | yes | — | Self-contained; assumes no chat history |
| `state` | string | no | `pending` | `complete`, `pending`, `blocked`, `not_applicable` (v2) |
| `required` | bool | no | `true` | Optional steps never block readiness (v2) |
### Last-seen health
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `status` | string | no (default `unknown`) | `healthy`, `degraded`, `unreachable`, `unknown` |
| `checked_at` | string | no | ISO-8601 UTC timestamp, e.g. `2026-01-01T00:00:00Z` |
| `detail` | string | no | Short redacted note |
Health is recorded metadata, not a live probe: Phase 1 performs no outbound
health checks. Endpoints, tokens, and keychain identifiers must never appear
here.
## Response shape
`GET /api/v1/projects`:
```json
{
"api_version": "v1",
"schema_version": 2,
"version": 2,
"source_path": "/path/to/webui/data/projects.registry.json",
"source": {
"kind": "file",
"path": "/path/to/webui/data/projects.registry.json",
"inventory_complete": true
},
"project_count": 1,
"projects": [
{
"id": "example",
"repo_name": "Example",
"gitea_owner": "Org",
"repo_full_name": "Org/Example",
"remote_host": "https://gitea.example.invalid",
"remote_name": "example-remote",
"default_branch": "main",
"local_checkout_path": ".",
"status": "active",
"profiles": {"author": "...", "reviewer": "...", "reconciler": "..."},
"workflow_paths": {"skill": "skills/..."},
"schema_paths": {},
"onboarding_checklist": [
{
"id": "profiles",
"title": "Configure execution profiles",
"description": "...",
"state": "complete",
"required": true
}
],
"onboarding_summary": {
"total": 1,
"complete": 1,
"pending": 0,
"blocked": 0,
"not_applicable": 0,
"required_outstanding": 0,
"onboarding_complete": true
},
"last_seen_health": null
}
]
}
```
`GET /api/v1/projects/{project_id}` returns `api_version`, `schema_version`,
`source`, and a single `project` object with the same fields.
The `source` block satisfies the ADR section 6 provenance rule: every payload
states where the data came from and whether the inventory is complete. A
file-backed registry is always complete — there is no pagination to truncate it.
`onboarding_summary` is derived, never stored. `required_outstanding` counts
steps that are `required` **and** in state `pending` or `blocked`;
`onboarding_complete` is true when that count is zero.
## Fail-closed errors
Validation failures raise `RegistryError`, which routes render instead of a
traceback.
`404` — unknown project id on `/api/v1/projects/{project_id}`:
```json
{
"error": "project_not_found",
"project_id": "not-registered",
"known_project_ids": ["example"],
"remediation": "Request one of the known project ids, or add the project ...",
"source": {"kind": "file", "path": "...", "inventory_complete": true}
}
```
`500` — invalid registry, on both the versioned route and the alias:
```json
{
"error": "registry_invalid",
"detail": "unsupported registry version: 42",
"remediation": "Set 'version' to one of 1, 2 (current schema is 2) ...",
"field_path": "version",
"source_path": "/path/to/registry.json"
}
```
`field_path` points at the offending location (`projects[0].profiles.reconciler`,
`projects[0].onboarding_checklist[2].state`, and so on). The HTML routes render
the same detail, field, source, and remediation on a "Project registry
unavailable" page.
Conditions that fail closed:
* file missing or unreadable;
* invalid JSON (the remediation names line and column);
* root not an object, or `projects` missing/empty;
* unsupported `version`;
* a credential-shaped key anywhere in the file (`token`, `*_secret`, `auth_*`, and similar);
* a project missing a required field, or missing an `author`/`reviewer`/`reconciler` profile;
* an unknown `status`, onboarding `state`, or health `status`.
## Credential rule
The registry stores redacted metadata only. Credential-shaped keys are
rejected at load time, before any DTO is built, consistent with
[safety-model.md](safety-model.md) and
[credential-isolation.md](credential-isolation.md). Tokens live in the keychain
and are resolved server-side by `gitea_auth`.
## Migrating a version 1 registry
1. Set `"version": 2`.
2. Optionally add `"status"` per project (omitted means `active`).
3. Optionally add `"remote_name"` per project.
4. Optionally add `"state"` and `"required"` to each onboarding step (omitted
means `pending` and `true`).
5. Optionally add `"last_seen_health"`.
No step is mandatory: a version 1 file keeps loading. Bumping the version only
declares that the file may use the v2 fields.
+335 -30
View File
@@ -1,4 +1,4 @@
"""Tests for web UI project registry (#427)."""
"""Tests for web UI project registry (#427) and its API evolution (#635)."""
import json
import sys
import tempfile
@@ -11,57 +11,246 @@ from starlette.testclient import TestClient
from webui.app import create_app
from webui.project_registry import (
CURRENT_SCHEMA_VERSION,
REGISTRY_API_VERSION,
SUPPORTED_SCHEMA_VERSIONS,
RegistryError,
default_registry_path,
load_registry,
onboarding_summary,
project_to_dict,
)
from webui.registry_safety import is_forbidden_key
_REPO_ROOT = Path(__file__).resolve().parent.parent
_API_DOC = _REPO_ROOT / "docs" / "webui-project-registry-api.md"
class TestProjectRegistryLoader(unittest.TestCase):
def _valid_project(**overrides):
project = {
"id": "example",
"repo_name": "Example",
"gitea_owner": "Org",
"remote_host": "https://gitea.example.invalid",
"default_branch": "main",
"local_checkout_path": ".",
"profiles": {"author": "a", "reviewer": "r", "reconciler": "c"},
"workflow_paths": {"skill": "skills/x.md"},
}
project.update(overrides)
return project
def _write_registry(payload) -> Path:
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
json.dump(payload, handle)
return Path(handle.name)
class RegistryFileCase(unittest.TestCase):
"""Base class that cleans up temporary registry files."""
def setUp(self):
self._temp_paths: list[Path] = []
def tearDown(self):
for path in self._temp_paths:
path.unlink(missing_ok=True)
def write_registry(self, payload) -> Path:
path = _write_registry(payload)
self._temp_paths.append(path)
return path
class TestProjectRegistryLoader(RegistryFileCase):
def test_default_registry_loads_gitea_tools(self):
registry = load_registry()
self.assertEqual(registry.version, 1)
self.assertEqual(registry.version, CURRENT_SCHEMA_VERSION)
self.assertEqual(registry.schema_version, CURRENT_SCHEMA_VERSION)
self.assertEqual(registry.api_version, REGISTRY_API_VERSION)
self.assertEqual(len(registry.projects), 1)
project = registry.projects[0]
self.assertEqual(project.id, "gitea-tools")
self.assertEqual(project.repo_name, "Gitea-Tools")
self.assertEqual(project.gitea_owner, "Scaled-Tech-Consulting")
self.assertEqual(project.repo_full_name, "Scaled-Tech-Consulting/Gitea-Tools")
self.assertEqual(project.remote_host, "https://gitea.prgs.cc")
self.assertEqual(project.remote_name, "prgs")
self.assertEqual(project.status, "active")
self.assertEqual(project.profiles["author"], "prgs-author")
self.assertEqual(project.profiles["reviewer"], "prgs-reviewer")
self.assertEqual(project.profiles["reconciler"], "prgs-reconciler")
self.assertIn("skill", project.workflow_paths)
self.assertGreaterEqual(len(project.onboarding_checklist), 4)
def test_registry_rejects_credential_keys(self):
payload = {
def test_default_registry_onboarding_summary_is_complete(self):
summary = onboarding_summary(load_registry().projects[0])
self.assertEqual(summary.total, summary.complete)
self.assertEqual(summary.required_outstanding, 0)
self.assertTrue(summary.onboarding_complete)
def test_version_1_registry_still_loads_with_defaults(self):
path = self.write_registry({
"version": 1,
"projects": [
{
"id": "bad",
"repo_name": "Bad",
"gitea_owner": "Org",
"remote_host": "https://gitea.example.invalid",
"default_branch": "main",
"local_checkout_path": ".",
"profiles": {
"author": "a",
"reviewer": "r",
"reconciler": "c",
},
"workflow_paths": {"skill": "skills/x.md"},
"api_token": "secret",
}
_valid_project(
onboarding_checklist=[
{"id": "step", "title": "Step", "description": "Do it"}
]
)
],
})
registry = load_registry(path)
self.assertEqual(registry.schema_version, 1)
self.assertIn(1, SUPPORTED_SCHEMA_VERSIONS)
project = registry.projects[0]
self.assertEqual(project.status, "active")
self.assertIsNone(project.remote_name)
self.assertIsNone(project.last_seen_health)
step = project.onboarding_checklist[0]
self.assertEqual(step.state, "pending")
self.assertTrue(step.required)
self.assertFalse(onboarding_summary(project).onboarding_complete)
def test_onboarding_summary_counts_states(self):
path = self.write_registry({
"version": 2,
"projects": [
_valid_project(
onboarding_checklist=[
{"id": "a", "title": "A", "description": "d", "state": "complete"},
{"id": "b", "title": "B", "description": "d", "state": "blocked"},
{
"id": "c",
"title": "C",
"description": "d",
"state": "pending",
"required": False,
},
{
"id": "d",
"title": "D",
"description": "d",
"state": "not_applicable",
},
]
)
],
})
summary = onboarding_summary(load_registry(path).projects[0])
self.assertEqual(summary.total, 4)
self.assertEqual(summary.complete, 1)
self.assertEqual(summary.blocked, 1)
self.assertEqual(summary.pending, 1)
self.assertEqual(summary.not_applicable, 1)
# Only the blocked step is both required and outstanding.
self.assertEqual(summary.required_outstanding, 1)
self.assertFalse(summary.onboarding_complete)
def test_last_seen_health_is_parsed_when_present(self):
path = self.write_registry({
"version": 2,
"projects": [
_valid_project(
last_seen_health={
"status": "degraded",
"checked_at": "2026-01-01T00:00:00Z",
"detail": "daemon restart pending",
}
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
json.dump(payload, handle)
path = Path(handle.name)
try:
with self.assertRaises(ValueError):
)
],
})
health = load_registry(path).projects[0].last_seen_health
self.assertIsNotNone(health)
self.assertEqual(health.status, "degraded")
self.assertEqual(health.checked_at, "2026-01-01T00:00:00Z")
def test_registry_rejects_credential_keys(self):
path = self.write_registry({
"version": 1,
"projects": [_valid_project(id="bad", api_token="redacted-placeholder")],
})
with self.assertRaises(RegistryError) as ctx:
load_registry(path)
finally:
path.unlink(missing_ok=True)
self.assertIn("credential", ctx.exception.remediation.lower())
self.assertEqual(ctx.exception.field_path, "projects[0].api_token")
def test_unsupported_version_fails_closed_with_remediation(self):
path = self.write_registry({"version": 99, "projects": [_valid_project()]})
with self.assertRaises(RegistryError) as ctx:
load_registry(path)
self.assertIn("unsupported registry version", ctx.exception.message)
self.assertIn(str(CURRENT_SCHEMA_VERSION), ctx.exception.remediation)
self.assertEqual(ctx.exception.field_path, "version")
def test_missing_required_field_fails_closed(self):
broken = _valid_project()
del broken["default_branch"]
path = self.write_registry({"version": 2, "projects": [broken]})
with self.assertRaises(RegistryError) as ctx:
load_registry(path)
self.assertIn("default_branch", ctx.exception.message)
self.assertEqual(ctx.exception.field_path, "projects[0]")
def test_unknown_status_fails_closed(self):
path = self.write_registry({
"version": 2,
"projects": [_valid_project(status="mystery")],
})
with self.assertRaises(RegistryError) as ctx:
load_registry(path)
self.assertEqual(ctx.exception.field_path, "projects[0].status")
self.assertIn("active", ctx.exception.remediation)
def test_unknown_onboarding_state_fails_closed(self):
path = self.write_registry({
"version": 2,
"projects": [
_valid_project(
onboarding_checklist=[
{"id": "a", "title": "A", "description": "d", "state": "almost"}
]
)
],
})
with self.assertRaises(RegistryError) as ctx:
load_registry(path)
self.assertEqual(
ctx.exception.field_path,
"projects[0].onboarding_checklist[0].state",
)
def test_missing_profile_role_fails_closed(self):
path = self.write_registry({
"version": 2,
"projects": [_valid_project(profiles={"author": "a", "reviewer": "r"})],
})
with self.assertRaises(RegistryError) as ctx:
load_registry(path)
self.assertEqual(ctx.exception.field_path, "projects[0].profiles.reconciler")
def test_empty_projects_fails_closed(self):
path = self.write_registry({"version": 2, "projects": []})
with self.assertRaises(RegistryError) as ctx:
load_registry(path)
self.assertEqual(ctx.exception.field_path, "projects")
def test_invalid_json_fails_closed_with_location(self):
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
handle.write("{not json")
path = Path(handle.name)
self._temp_paths.append(path)
with self.assertRaises(RegistryError) as ctx:
load_registry(path)
self.assertIn("not valid JSON", ctx.exception.message)
self.assertIn("line", ctx.exception.remediation)
def test_missing_file_fails_closed(self):
missing = Path(tempfile.gettempdir()) / "webui-registry-does-not-exist.json"
with self.assertRaises(RegistryError) as ctx:
load_registry(missing)
self.assertIn("could not be read", ctx.exception.message)
def test_default_registry_path_points_at_packaged_data(self):
path = default_registry_path()
@@ -81,30 +270,146 @@ class TestProjectRegistryRoutes(unittest.TestCase):
self.assertIn("prgs-author", response.text)
self.assertNotIn("child issue", response.text.lower())
def test_projects_page_shows_status_and_progress(self):
response = self.client.get("/projects")
self.assertIn("Status", response.text)
self.assertIn("Onboarding", response.text)
self.assertIn("4/4 complete", response.text)
def test_project_detail_renders_checklist(self):
response = self.client.get("/projects/gitea-tools")
self.assertEqual(response.status_code, 200)
self.assertIn("Onboarding checklist", response.text)
self.assertIn("Configure execution profiles", response.text)
self.assertIn("branches/", response.text)
self.assertIn("Complete", response.text)
self.assertIn("required outstanding 0", response.text)
def test_project_detail_404(self):
response = self.client.get("/projects/unknown-repo")
self.assertEqual(response.status_code, 404)
def test_api_projects_json(self):
def test_api_projects_alias_stays_compatible(self):
response = self.client.get("/api/projects")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["version"], 1)
# #427 consumers keep these keys.
self.assertEqual(data["version"], CURRENT_SCHEMA_VERSION)
self.assertIn("source_path", data)
self.assertEqual(len(data["projects"]), 1)
self.assertEqual(data["projects"][0]["id"], "gitea-tools")
self.assertIn("onboarding_checklist", data["projects"][0])
def test_api_v1_projects_payload(self):
response = self.client.get("/api/v1/projects")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["api_version"], REGISTRY_API_VERSION)
self.assertEqual(data["schema_version"], CURRENT_SCHEMA_VERSION)
self.assertEqual(data["project_count"], 1)
self.assertEqual(data["source"]["kind"], "file")
self.assertTrue(data["source"]["inventory_complete"])
project = data["projects"][0]
self.assertEqual(project["status"], "active")
self.assertEqual(project["remote_name"], "prgs")
self.assertEqual(
project["repo_full_name"], "Scaled-Tech-Consulting/Gitea-Tools"
)
self.assertTrue(project["onboarding_summary"]["onboarding_complete"])
self.assertEqual(project["onboarding_checklist"][0]["state"], "complete")
self.assertIsNone(project["last_seen_health"])
def test_api_v1_project_detail(self):
response = self.client.get("/api/v1/projects/gitea-tools")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["api_version"], REGISTRY_API_VERSION)
self.assertEqual(data["project"]["id"], "gitea-tools")
self.assertEqual(data["source"]["kind"], "file")
def test_api_v1_project_detail_missing_fails_closed(self):
response = self.client.get("/api/v1/projects/not-registered")
self.assertEqual(response.status_code, 404)
data = response.json()
self.assertEqual(data["error"], "project_not_found")
self.assertEqual(data["project_id"], "not-registered")
self.assertIn("gitea-tools", data["known_project_ids"])
self.assertIn("remediation", data)
def test_api_v1_projects_is_read_only(self):
response = self.client.post("/api/v1/projects", json={})
self.assertEqual(response.status_code, 405)
self.assertEqual(response.json()["error"], "read-only-mvp")
def test_project_to_dict_is_json_safe(self):
registry = load_registry()
encoded = json.dumps(project_to_dict(registry.projects[0]))
dto = project_to_dict(registry.projects[0])
encoded = json.dumps(dto)
self.assertIn("gitea-tools", encoded)
# Prose may mention tokens; no serialized *key* may look like a secret.
for key in dto:
with self.subTest(key=key):
self.assertFalse(is_forbidden_key(key))
class TestInvalidRegistryFailsClosedOverHttp(RegistryFileCase):
def setUp(self):
super().setUp()
self.path = self.write_registry({"version": 42, "projects": []})
self.client = TestClient(create_app())
def _with_bad_registry(self, url: str):
import os
from unittest import mock
with mock.patch.dict(
os.environ, {"WEBUI_PROJECT_REGISTRY": str(self.path)}, clear=False
):
return self.client.get(url)
def test_api_v1_reports_actionable_error(self):
response = self._with_bad_registry("/api/v1/projects")
self.assertEqual(response.status_code, 500)
data = response.json()
self.assertEqual(data["error"], "registry_invalid")
self.assertIn("unsupported registry version", data["detail"])
self.assertTrue(data["remediation"])
self.assertEqual(data["field_path"], "version")
def test_unversioned_alias_reports_actionable_error(self):
response = self._with_bad_registry("/api/projects")
self.assertEqual(response.status_code, 500)
self.assertEqual(response.json()["error"], "registry_invalid")
def test_html_page_reports_actionable_error(self):
response = self._with_bad_registry("/projects")
self.assertEqual(response.status_code, 500)
self.assertIn("Project registry unavailable", response.text)
self.assertIn("Remediation", response.text)
class TestProjectRegistryApiDocs(unittest.TestCase):
def test_api_contract_is_documented(self):
self.assertTrue(_API_DOC.is_file(), f"missing {_API_DOC}")
text = _API_DOC.read_text(encoding="utf-8")
for token in (
"/api/v1/projects",
"/api/v1/projects/{project_id}",
"/api/projects",
"onboarding_summary",
"last_seen_health",
"registry_invalid",
"#635",
):
with self.subTest(token=token):
self.assertIn(token, text)
def test_route_table_lists_versioned_routes(self):
local_dev = (_REPO_ROOT / "docs" / "webui-local-dev.md").read_text(
encoding="utf-8"
)
self.assertIn("/api/v1/projects", local_dev)
self.assertIn("webui-project-registry-api.md", local_dev)
if __name__ == "__main__":
-499
View File
@@ -1,499 +0,0 @@
"""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()
+72 -39
View File
@@ -11,8 +11,20 @@ from starlette.routing import Route
from webui.deployment_boundary import deployment_snapshot
from webui.layout import render_page
from webui.project_registry import find_project, load_registry, registry_to_dict
from webui.project_views import render_project_detail, render_projects_list
from webui.project_registry import (
ProjectRegistry,
RegistryError,
find_project,
known_project_ids,
load_registry,
project_detail_to_dict,
registry_to_dict,
)
from webui.project_views import (
render_project_detail,
render_projects_list,
render_registry_error,
)
from webui.prompt_library import find_prompt, library_to_dict
from webui.prompt_views import render_prompt_detail, render_prompts_page
from final_report_validator import FINAL_REPORT_TASK_KINDS
@@ -29,12 +41,6 @@ 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"})
@@ -68,43 +74,16 @@ 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)))
@@ -114,14 +93,26 @@ async def api_queue(_request: Request) -> JSONResponse:
return JSONResponse(queue_snapshot_to_dict(load_queue_snapshot()))
def _load_project_registry() -> tuple[ProjectRegistry | None, RegistryError | None]:
"""Load the registry, converting validation failure into a fail-closed pair."""
try:
return load_registry(), None
except RegistryError as exc:
return None, exc
async def projects(_request: Request) -> HTMLResponse:
registry = load_registry()
registry, error = _load_project_registry()
if error is not None:
return HTMLResponse(render_registry_error(error), status_code=500)
return HTMLResponse(render_projects_list(registry))
async def project_detail(request: Request) -> HTMLResponse:
project_id = request.path_params["project_id"]
registry = load_registry()
registry, error = _load_project_registry()
if error is not None:
return HTMLResponse(render_registry_error(error), status_code=500)
project = find_project(registry, project_id)
if project is None:
return HTMLResponse(
@@ -139,10 +130,47 @@ async def project_detail(request: Request) -> HTMLResponse:
async def api_projects(_request: Request) -> JSONResponse:
registry = load_registry()
"""Unversioned MVP alias, retained through Phase 1 (#632 section 6)."""
registry, error = _load_project_registry()
if error is not None:
return JSONResponse(error.to_dict(), status_code=500)
return JSONResponse(registry_to_dict(registry))
async def api_v1_projects(_request: Request) -> JSONResponse:
registry, error = _load_project_registry()
if error is not None:
return JSONResponse(error.to_dict(), status_code=500)
return JSONResponse(registry_to_dict(registry))
async def api_v1_project_detail(request: Request) -> JSONResponse:
project_id = request.path_params["project_id"]
registry, error = _load_project_registry()
if error is not None:
return JSONResponse(error.to_dict(), status_code=500)
project = find_project(registry, project_id)
if project is None:
return JSONResponse(
{
"error": "project_not_found",
"project_id": project_id,
"known_project_ids": known_project_ids(registry),
"remediation": (
"Request one of the known project ids, or add the project to the "
"registry file named in 'source'."
),
"source": {
"kind": "file",
"path": str(registry.source_path),
"inventory_complete": True,
},
},
status_code=404,
)
return JSONResponse(project_detail_to_dict(registry, project))
async def prompts(_request: Request) -> HTMLResponse:
return HTMLResponse(render_prompts_page())
@@ -296,12 +324,17 @@ 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"]),
Route("/projects/{project_id}", project_detail, methods=["GET"]),
Route("/api/projects", api_projects, methods=["GET"]),
Route("/api/v1/projects", api_v1_projects, methods=["GET"]),
Route(
"/api/v1/projects/{project_id}",
api_v1_project_detail,
methods=["GET"],
),
Route("/prompts", prompts, methods=["GET"]),
Route("/prompts/{prompt_id}", prompt_detail, methods=["GET"]),
Route("/api/prompts", api_prompts, methods=["GET"]),
+15 -5
View File
@@ -1,13 +1,15 @@
{
"version": 1,
"version": 2,
"projects": [
{
"id": "gitea-tools",
"repo_name": "Gitea-Tools",
"gitea_owner": "Scaled-Tech-Consulting",
"remote_name": "prgs",
"remote_host": "https://gitea.prgs.cc",
"default_branch": "master",
"local_checkout_path": ".",
"status": "active",
"profiles": {
"author": "prgs-author",
"reviewer": "prgs-reviewer",
@@ -26,22 +28,30 @@
{
"id": "profiles",
"title": "Configure execution profiles",
"description": "Install author, reviewer, and reconciler MCP profiles (prgs-author, prgs-reviewer, prgs-reconciler) in separate namespaces. Tokens stay in keychain — never in this registry."
"description": "Install author, reviewer, and reconciler MCP profiles (prgs-author, prgs-reviewer, prgs-reconciler) in separate namespaces. Tokens stay in keychain — never in this registry.",
"state": "complete",
"required": true
},
{
"id": "mcp_config",
"title": "Wire MCP v2 contexts",
"description": "Copy and customize gitea-mcp.v2-contexts.example.json for your machine. Map this repo path under projects with default_owner Scaled-Tech-Consulting and default_repo Gitea-Tools."
"description": "Copy and customize gitea-mcp.v2-contexts.example.json for your machine. Map this repo path under projects with default_owner Scaled-Tech-Consulting and default_repo Gitea-Tools.",
"state": "complete",
"required": true
},
{
"id": "wiki_gate",
"title": "Wiki publication readiness",
"description": "For wiki-tracked work, satisfy the live Gitea Wiki proof gate (#224) before closing issues. See docs/wiki/Safety-and-Gates.md."
"description": "For wiki-tracked work, satisfy the live Gitea Wiki proof gate (#224) before closing issues. See docs/wiki/Safety-and-Gates.md.",
"state": "complete",
"required": true
},
{
"id": "branches_layout",
"title": "Isolate work under branches/",
"description": "All LLM task edits happen in worktrees under branches/. Main checkout stays clean; use skills/llm-project-workflow templates for start-issue and review flows."
"description": "All LLM task edits happen in worktrees under branches/. Main checkout stays clean; use skills/llm-project-workflow templates for start-issue and review flows.",
"state": "complete",
"required": true
}
]
}
+457 -22
View File
@@ -1,4 +1,16 @@
"""Load and validate the web UI project registry (#427)."""
"""Load and validate the web UI project registry (#427, evolved for #635).
Phase 1 of the console architecture ADR keeps this loader read-only. It owns
the versioned project registry contract served at ``/api/v1/projects``:
* the on-disk file carries a ``version`` (schema version 1 or 2);
* version 1 files stay loadable and are normalized with explicit defaults, so
an operator registry written for #427 keeps working;
* every validation failure raises :class:`RegistryError`, which carries an
actionable ``remediation`` string instead of leaking a traceback;
* serialization never emits credentials — credential-shaped keys are rejected
at load time, before any DTO is built.
"""
from __future__ import annotations
@@ -8,7 +20,59 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any
from webui.registry_safety import reject_credential_keys as _reject_credential_keys
from webui.registry_safety import is_forbidden_key
#: Version of the JSON contract served under ``/api/v1/...``.
REGISTRY_API_VERSION = "v1"
#: Schema version written by this repository's packaged registry.
CURRENT_SCHEMA_VERSION = 2
#: Schema versions this loader accepts. Version 1 is normalized on load.
SUPPORTED_SCHEMA_VERSIONS = (1, 2)
#: Lifecycle state of a registered project.
PROJECT_STATUSES = ("active", "onboarding", "paused", "archived")
_DEFAULT_PROJECT_STATUS = "active"
#: Completion state of a single onboarding step.
ONBOARDING_STATES = ("complete", "pending", "blocked", "not_applicable")
_DEFAULT_ONBOARDING_STATE = "pending"
#: Redacted, last-seen health of a project's control plane.
HEALTH_STATUSES = ("healthy", "degraded", "unreachable", "unknown")
class RegistryError(ValueError):
"""A registry file could not be loaded or failed validation.
Carries an operator-facing ``remediation`` so routes can fail closed with
an actionable message rather than a stack trace.
"""
def __init__(
self,
message: str,
*,
remediation: str,
source_path: Path | None = None,
field_path: str | None = None,
) -> None:
super().__init__(message)
self.message = message
self.remediation = remediation
self.source_path = source_path
self.field_path = field_path
def to_dict(self) -> dict[str, Any]:
"""Serialize for a fail-closed JSON error response."""
return {
"error": "registry_invalid",
"detail": self.message,
"remediation": self.remediation,
"field_path": self.field_path,
"source_path": str(self.source_path) if self.source_path else None,
}
_REQUIRED_PROJECT_FIELDS = (
"id",
@@ -29,6 +93,30 @@ class OnboardingStep:
id: str
title: str
description: str
state: str = _DEFAULT_ONBOARDING_STATE
required: bool = True
@dataclass(frozen=True)
class OnboardingSummary:
"""Aggregate onboarding progress for a single project."""
total: int
complete: int
pending: int
blocked: int
not_applicable: int
required_outstanding: int
onboarding_complete: bool
@dataclass(frozen=True)
class ProjectHealth:
"""Redacted last-seen health. Never carries endpoints or credentials."""
status: str
checked_at: str | None
detail: str | None
@dataclass(frozen=True)
@@ -43,6 +131,13 @@ class ProjectRecord:
workflow_paths: dict[str, str]
schema_paths: dict[str, str]
onboarding_checklist: tuple[OnboardingStep, ...]
status: str = _DEFAULT_PROJECT_STATUS
remote_name: str | None = None
last_seen_health: ProjectHealth | None = None
@property
def repo_full_name(self) -> str:
return f"{self.gitea_owner}/{self.repo_name}"
@dataclass(frozen=True)
@@ -51,6 +146,15 @@ class ProjectRegistry:
projects: tuple[ProjectRecord, ...]
source_path: Path
@property
def schema_version(self) -> int:
"""Alias of :attr:`version` — the schema version read from disk."""
return self.version
@property
def api_version(self) -> str:
return REGISTRY_API_VERSION
def default_registry_path() -> Path:
override = os.environ.get("WEBUI_PROJECT_REGISTRY", "").strip()
@@ -59,40 +163,221 @@ def default_registry_path() -> Path:
return (Path(__file__).resolve().parent / "data" / "projects.registry.json").resolve()
def _parse_onboarding(raw: list[dict[str, Any]] | None) -> tuple[OnboardingStep, ...]:
if not raw:
def _reject_credential_keys(obj: Any, *, path: str = "", source: Path | None = None) -> None:
"""Recursive credential-key guard that reports an actionable ``field_path``.
Key *shape* is decided by :func:`webui.registry_safety.is_forbidden_key`, the
single source of truth shared with the worker registry (#798).
"""
if isinstance(obj, dict):
for key, value in obj.items():
key_path = f"{path}.{key}" if path else key
if is_forbidden_key(key):
raise RegistryError(
f"registry must not store credentials ({key_path})",
remediation=(
f"Remove the credential-shaped key '{key_path}' from the registry. "
"Tokens live in the keychain and are resolved server-side by "
"gitea_auth; the registry is redacted metadata only."
),
source_path=source,
field_path=key_path,
)
_reject_credential_keys(value, path=key_path, source=source)
elif isinstance(obj, list):
for index, item in enumerate(obj):
_reject_credential_keys(item, path=f"{path}[{index}]", source=source)
def _require_enum(
value: Any,
*,
allowed: tuple[str, ...],
field_path: str,
source: Path | None,
) -> str:
text = str(value)
if text not in allowed:
raise RegistryError(
f"{field_path} must be one of {', '.join(allowed)} (got {text!r})",
remediation=(
f"Set {field_path} to one of: {', '.join(allowed)}. "
"Unknown values fail closed so the console never renders an "
"unverified state."
),
source_path=source,
field_path=field_path,
)
return text
def _parse_onboarding(
raw: Any,
*,
project_path: str,
source: Path | None,
) -> tuple[OnboardingStep, ...]:
if raw is None:
return ()
if not isinstance(raw, list):
raise RegistryError(
f"{project_path}.onboarding_checklist must be an array",
remediation=(
f"Rewrite {project_path}.onboarding_checklist as a JSON array of "
"steps with id, title, description, and optional state."
),
source_path=source,
field_path=f"{project_path}.onboarding_checklist",
)
steps: list[OnboardingStep] = []
for item in raw:
for index, item in enumerate(raw):
step_path = f"{project_path}.onboarding_checklist[{index}]"
if not isinstance(item, dict):
raise RegistryError(
f"{step_path} must be an object",
remediation=f"Rewrite {step_path} as an object with id, title, description.",
source_path=source,
field_path=step_path,
)
missing = [field for field in ("id", "title", "description") if field not in item]
if missing:
raise RegistryError(
f"{step_path} missing required fields: {', '.join(missing)}",
remediation=(
f"Add {', '.join(missing)} to {step_path}. Every onboarding step "
"must be self-describing for an operator who has no chat history."
),
source_path=source,
field_path=step_path,
)
state = _require_enum(
item.get("state", _DEFAULT_ONBOARDING_STATE),
allowed=ONBOARDING_STATES,
field_path=f"{step_path}.state",
source=source,
)
steps.append(
OnboardingStep(
id=str(item["id"]),
title=str(item["title"]),
description=str(item["description"]),
state=state,
required=bool(item.get("required", True)),
)
)
return tuple(steps)
def _parse_project(raw: dict[str, Any]) -> ProjectRecord:
def _parse_health(
raw: Any,
*,
project_path: str,
source: Path | None,
) -> ProjectHealth | None:
if raw is None:
return None
if not isinstance(raw, dict):
raise RegistryError(
f"{project_path}.last_seen_health must be an object when present",
remediation=(
f"Rewrite {project_path}.last_seen_health as an object with status "
f"(one of {', '.join(HEALTH_STATUSES)}), optional checked_at and detail, "
"or remove it. Never store endpoints or credentials here."
),
source_path=source,
field_path=f"{project_path}.last_seen_health",
)
status = _require_enum(
raw.get("status", "unknown"),
allowed=HEALTH_STATUSES,
field_path=f"{project_path}.last_seen_health.status",
source=source,
)
checked_at = raw.get("checked_at")
detail = raw.get("detail")
return ProjectHealth(
status=status,
checked_at=str(checked_at) if checked_at is not None else None,
detail=str(detail) if detail is not None else None,
)
def _parse_project(raw: Any, *, index: int, source: Path | None) -> ProjectRecord:
project_path = f"projects[{index}]"
if not isinstance(raw, dict):
raise RegistryError(
f"{project_path} must be an object",
remediation=f"Rewrite {project_path} as a JSON object describing one project.",
source_path=source,
field_path=project_path,
)
missing = [field for field in _REQUIRED_PROJECT_FIELDS if field not in raw]
if missing:
raise ValueError(f"project missing required fields: {', '.join(missing)}")
raise RegistryError(
f"{project_path} missing required fields: {', '.join(missing)}",
remediation=(
f"Add {', '.join(missing)} to {project_path}. See "
"docs/webui-project-registry-api.md for the field-by-field contract."
),
source_path=source,
field_path=project_path,
)
profiles = raw["profiles"]
if not isinstance(profiles, dict):
raise ValueError("profiles must be an object")
raise RegistryError(
f"{project_path}.profiles must be an object",
remediation=(
f"Rewrite {project_path}.profiles as an object mapping "
f"{', '.join(_REQUIRED_PROFILE_ROLES)} to MCP profile names."
),
source_path=source,
field_path=f"{project_path}.profiles",
)
for role in _REQUIRED_PROFILE_ROLES:
if role not in profiles or not profiles[role]:
raise ValueError(f"profiles.{role} is required")
raise RegistryError(
f"{project_path}.profiles.{role} is required",
remediation=(
f"Set {project_path}.profiles.{role} to the configured MCP profile "
"name for that role. Role separation is a workflow-safety invariant."
),
source_path=source,
field_path=f"{project_path}.profiles.{role}",
)
workflow_paths = raw["workflow_paths"]
if not isinstance(workflow_paths, dict) or not workflow_paths:
raise ValueError("workflow_paths must be a non-empty object")
raise RegistryError(
f"{project_path}.workflow_paths must be a non-empty object",
remediation=(
f"Add at least a 'skill' entry to {project_path}.workflow_paths pointing "
"at the project's canonical workflow skill."
),
source_path=source,
field_path=f"{project_path}.workflow_paths",
)
schema_paths = raw.get("schema_paths") or {}
if not isinstance(schema_paths, dict):
raise ValueError("schema_paths must be an object when present")
raise RegistryError(
f"{project_path}.schema_paths must be an object when present",
remediation=(
f"Rewrite {project_path}.schema_paths as an object of label to repo path, "
"or remove it."
),
source_path=source,
field_path=f"{project_path}.schema_paths",
)
status = _require_enum(
raw.get("status", _DEFAULT_PROJECT_STATUS),
allowed=PROJECT_STATUSES,
field_path=f"{project_path}.status",
source=source,
)
remote_name = raw.get("remote_name")
return ProjectRecord(
id=str(raw["id"]),
@@ -104,61 +389,211 @@ def _parse_project(raw: dict[str, Any]) -> ProjectRecord:
profiles={role: str(profiles[role]) for role in _REQUIRED_PROFILE_ROLES},
workflow_paths={key: str(value) for key, value in workflow_paths.items()},
schema_paths={key: str(value) for key, value in schema_paths.items()},
onboarding_checklist=_parse_onboarding(raw.get("onboarding_checklist")),
onboarding_checklist=_parse_onboarding(
raw.get("onboarding_checklist"),
project_path=project_path,
source=source,
),
status=status,
remote_name=str(remote_name) if remote_name else None,
last_seen_health=_parse_health(
raw.get("last_seen_health"),
project_path=project_path,
source=source,
),
)
def load_registry(path: Path | None = None) -> ProjectRegistry:
"""Load the versioned project registry from disk."""
"""Load the versioned project registry from disk.
Raises:
RegistryError: whenever the file is unreadable, is not valid JSON, or
fails schema validation. The error carries an operator remediation.
"""
source = (path or default_registry_path()).resolve()
try:
raw_text = source.read_text(encoding="utf-8")
except OSError as exc:
raise RegistryError(
f"registry file could not be read: {exc.strerror or exc}",
remediation=(
f"Create a readable registry at {source}, or point "
"WEBUI_PROJECT_REGISTRY at an existing file."
),
source_path=source,
) from exc
try:
payload = json.loads(raw_text)
except json.JSONDecodeError as exc:
raise RegistryError(
f"registry is not valid JSON: {exc.msg} (line {exc.lineno}, column {exc.colno})",
remediation=(
f"Fix the JSON syntax in {source} at line {exc.lineno}, column {exc.colno}."
),
source_path=source,
) from exc
if not isinstance(payload, dict):
raise ValueError("registry root must be an object")
raise RegistryError(
"registry root must be an object",
remediation=(
"Wrap the registry in a JSON object with 'version' and 'projects' keys."
),
source_path=source,
)
version = payload.get("version")
if version != 1:
raise ValueError(f"unsupported registry version: {version!r}")
if version not in SUPPORTED_SCHEMA_VERSIONS:
supported = ", ".join(str(item) for item in SUPPORTED_SCHEMA_VERSIONS)
raise RegistryError(
f"unsupported registry version: {version!r}",
remediation=(
f"Set 'version' to one of {supported} (current schema is "
f"{CURRENT_SCHEMA_VERSION}). Migration notes live in "
"docs/webui-project-registry-api.md."
),
source_path=source,
field_path="version",
)
_reject_credential_keys(payload)
_reject_credential_keys(payload, source=source)
projects_raw = payload.get("projects")
if not isinstance(projects_raw, list) or not projects_raw:
raise ValueError("projects must be a non-empty array")
raise RegistryError(
"projects must be a non-empty array",
remediation=(
"Add at least one project object to 'projects'. An empty console "
"registry fails closed rather than rendering a blank inventory."
),
source_path=source,
field_path="projects",
)
projects = tuple(_parse_project(item) for item in projects_raw)
return ProjectRegistry(version=version, projects=projects, source_path=source)
projects = tuple(
_parse_project(item, index=index, source=source)
for index, item in enumerate(projects_raw)
)
return ProjectRegistry(version=int(version), projects=projects, source_path=source)
def onboarding_summary(project: ProjectRecord) -> OnboardingSummary:
"""Aggregate a project's onboarding checklist state."""
steps = project.onboarding_checklist
counts = {state: 0 for state in ONBOARDING_STATES}
for step in steps:
counts[step.state] += 1
required_outstanding = sum(
1
for step in steps
if step.required and step.state in ("pending", "blocked")
)
return OnboardingSummary(
total=len(steps),
complete=counts["complete"],
pending=counts["pending"],
blocked=counts["blocked"],
not_applicable=counts["not_applicable"],
required_outstanding=required_outstanding,
onboarding_complete=required_outstanding == 0,
)
def project_to_dict(project: ProjectRecord) -> dict[str, Any]:
"""Serialize a project for JSON API responses."""
"""Serialize a project for JSON API responses and HTML views.
The HTML views render from this same DTO, so the console and the API can
never disagree about a project's status or onboarding progress.
"""
summary = onboarding_summary(project)
health = project.last_seen_health
return {
"id": project.id,
"repo_name": project.repo_name,
"gitea_owner": project.gitea_owner,
"repo_full_name": project.repo_full_name,
"remote_host": project.remote_host,
"remote_name": project.remote_name,
"default_branch": project.default_branch,
"local_checkout_path": project.local_checkout_path,
"status": project.status,
"profiles": dict(project.profiles),
"workflow_paths": dict(project.workflow_paths),
"schema_paths": dict(project.schema_paths),
"onboarding_checklist": [
{"id": step.id, "title": step.title, "description": step.description}
{
"id": step.id,
"title": step.title,
"description": step.description,
"state": step.state,
"required": step.required,
}
for step in project.onboarding_checklist
],
"onboarding_summary": {
"total": summary.total,
"complete": summary.complete,
"pending": summary.pending,
"blocked": summary.blocked,
"not_applicable": summary.not_applicable,
"required_outstanding": summary.required_outstanding,
"onboarding_complete": summary.onboarding_complete,
},
"last_seen_health": (
None
if health is None
else {
"status": health.status,
"checked_at": health.checked_at,
"detail": health.detail,
}
),
}
def registry_to_dict(registry: ProjectRegistry) -> dict[str, Any]:
"""Serialize the whole registry, including API provenance (#632 section 6)."""
return {
"api_version": registry.api_version,
"schema_version": registry.schema_version,
# Retained for the unversioned MVP alias consumers (#427).
"version": registry.version,
"source_path": str(registry.source_path),
"source": {
"kind": "file",
"path": str(registry.source_path),
"inventory_complete": True,
},
"project_count": len(registry.projects),
"projects": [project_to_dict(project) for project in registry.projects],
}
def project_detail_to_dict(
registry: ProjectRegistry,
project: ProjectRecord,
) -> dict[str, Any]:
"""Serialize a single project for ``/api/v1/projects/{project_id}``."""
return {
"api_version": registry.api_version,
"schema_version": registry.schema_version,
"source": {
"kind": "file",
"path": str(registry.source_path),
"inventory_complete": True,
},
"project": project_to_dict(project),
}
def find_project(registry: ProjectRegistry, project_id: str) -> ProjectRecord | None:
for project in registry.projects:
if project.id == project_id:
return project
return None
def known_project_ids(registry: ProjectRegistry) -> list[str]:
return [project.id for project in registry.projects]
+107 -25
View File
@@ -1,34 +1,65 @@
"""HTML views for project registry pages (#427)."""
"""HTML views for project registry pages (#427, evolved for #635).
Every view renders from :func:`webui.project_registry.project_to_dict`, the
same DTO the ``/api/v1/projects`` JSON responses use, so the HTML console and
the API can never disagree about status or onboarding progress.
"""
from __future__ import annotations
import html
from typing import Any
from webui.layout import render_page
from webui.project_registry import ProjectRecord, ProjectRegistry
from webui.project_registry import (
ProjectRecord,
ProjectRegistry,
RegistryError,
project_to_dict,
)
_STATE_LABELS = {
"complete": "Complete",
"pending": "Pending",
"blocked": "Blocked",
"not_applicable": "Not applicable",
}
def _escape(text: str) -> str:
return html.escape(text, quote=True)
def _progress_label(summary: dict[str, Any]) -> str:
total = summary["total"]
if not total:
return "no steps"
label = f"{summary['complete']}/{total} complete"
if summary["blocked"]:
label += f", {summary['blocked']} blocked"
return label
def render_projects_list(registry: ProjectRegistry) -> str:
rows = []
for project in registry.projects:
dto = project_to_dict(project)
rows.append(
"<tr>"
f"<td><a href=\"/projects/{_escape(project.id)}\">{_escape(project.repo_name)}</a></td>"
f"<td>{_escape(project.gitea_owner)}</td>"
f"<td>{_escape(project.remote_host)}</td>"
f"<td>{_escape(project.default_branch)}</td>"
f"<td><code>{_escape(project.profiles['author'])}</code></td>"
f"<td><a href=\"/projects/{_escape(dto['id'])}\">{_escape(dto['repo_name'])}</a></td>"
f"<td>{_escape(dto['gitea_owner'])}</td>"
f"<td>{_escape(dto['remote_host'])}</td>"
f"<td>{_escape(dto['default_branch'])}</td>"
f"<td><code>{_escape(dto['status'])}</code></td>"
f"<td>{_escape(_progress_label(dto['onboarding_summary']))}</td>"
f"<td><code>{_escape(dto['profiles']['author'])}</code></td>"
"</tr>"
)
table = (
"<table class=\"registry\">"
"<thead><tr>"
"<th>Repository</th><th>Owner</th><th>Remote</th>"
"<th>Branch</th><th>Author profile</th>"
"<th>Branch</th><th>Status</th><th>Onboarding</th><th>Author profile</th>"
"</tr></thead>"
f"<tbody>{''.join(rows)}</tbody></table>"
)
@@ -36,32 +67,39 @@ def render_projects_list(registry: ProjectRegistry) -> str:
"<h2>Projects</h2>"
"<p>Configured repositories managed by the MCP Control Plane.</p>"
f"<p class=\"meta\">Registry: <code>{_escape(str(registry.source_path))}</code> "
f"(version {registry.version})</p>"
f"(schema version {registry.schema_version}, "
f"API {_escape(registry.api_version)})</p>"
f"{table}"
"<p><a href=\"/api/projects\">JSON API</a></p>"
"<p><a href=\"/api/v1/projects\">JSON API</a> "
"(<a href=\"/api/projects\">unversioned alias</a>)</p>"
)
return render_page(title="Projects", body_html=body)
def render_project_detail(project: ProjectRecord) -> str:
dto = project_to_dict(project)
profile_rows = "".join(
f"<tr><th>{_escape(role)}</th><td><code>{_escape(name)}</code></td></tr>"
for role, name in project.profiles.items()
for role, name in dto["profiles"].items()
)
workflow_rows = "".join(
f"<tr><th>{_escape(key)}</th><td><code>{_escape(path)}</code></td></tr>"
for key, path in project.workflow_paths.items()
for key, path in dto["workflow_paths"].items()
)
schema_rows = "".join(
f"<tr><th>{_escape(key)}</th><td><code>{_escape(path)}</code></td></tr>"
for key, path in project.schema_paths.items()
for key, path in dto["schema_paths"].items()
)
checklist_items = []
for index, step in enumerate(project.onboarding_checklist, start=1):
for index, step in enumerate(dto["onboarding_checklist"], start=1):
state_label = _STATE_LABELS.get(step["state"], step["state"])
requirement = "required" if step["required"] else "optional"
checklist_items.append(
"<li>"
f"<strong>{index}. {_escape(step.title)}</strong>"
f"<p>{_escape(step.description)}</p>"
f"<li class=\"step-{_escape(step['state'])}\">"
f"<strong>{index}. {_escape(step['title'])}</strong>"
f" <span class=\"badge\">{_escape(state_label)}</span>"
f" <span class=\"meta\">({_escape(requirement)})</span>"
f"<p>{_escape(step['description'])}</p>"
"</li>"
)
checklist_html = (
@@ -69,16 +107,38 @@ def render_project_detail(project: ProjectRecord) -> str:
if checklist_items
else "<p>No onboarding steps defined.</p>"
)
summary = dto["onboarding_summary"]
summary_html = (
"<p class=\"meta\">Onboarding: "
f"{_escape(_progress_label(summary))}; required outstanding "
f"{summary['required_outstanding']}.</p>"
)
health = dto["last_seen_health"]
health_html = (
"<p class=\"meta\">No health probe recorded (Phase 1 is read-only).</p>"
if health is None
else (
"<table class=\"detail\">"
f"<tr><th>Status</th><td><code>{_escape(health['status'])}</code></td></tr>"
f"<tr><th>Checked at</th><td>{_escape(str(health['checked_at'] or 'unknown'))}</td></tr>"
f"<tr><th>Detail</th><td>{_escape(str(health['detail'] or ''))}</td></tr>"
"</table>"
)
)
remote_name = dto["remote_name"] or "unset"
body = (
f"<h2>{_escape(project.repo_name)}</h2>"
f"<h2>{_escape(dto['repo_name'])}</h2>"
"<p><a href=\"/projects\">← All projects</a></p>"
"<h3>Identity</h3>"
"<table class=\"detail\">"
f"<tr><th>Registry id</th><td><code>{_escape(project.id)}</code></td></tr>"
f"<tr><th>Gitea owner</th><td>{_escape(project.gitea_owner)}</td></tr>"
f"<tr><th>Remote host</th><td>{_escape(project.remote_host)}</td></tr>"
f"<tr><th>Default branch</th><td><code>{_escape(project.default_branch)}</code></td></tr>"
f"<tr><th>Local checkout</th><td><code>{_escape(project.local_checkout_path)}</code></td></tr>"
f"<tr><th>Registry id</th><td><code>{_escape(dto['id'])}</code></td></tr>"
f"<tr><th>Status</th><td><code>{_escape(dto['status'])}</code></td></tr>"
f"<tr><th>Gitea owner</th><td>{_escape(dto['gitea_owner'])}</td></tr>"
f"<tr><th>Repository</th><td><code>{_escape(dto['repo_full_name'])}</code></td></tr>"
f"<tr><th>Remote name</th><td><code>{_escape(remote_name)}</code></td></tr>"
f"<tr><th>Remote host</th><td>{_escape(dto['remote_host'])}</td></tr>"
f"<tr><th>Default branch</th><td><code>{_escape(dto['default_branch'])}</code></td></tr>"
f"<tr><th>Local checkout</th><td><code>{_escape(dto['local_checkout_path'])}</code></td></tr>"
"</table>"
"<h3>Profiles</h3>"
f"<table class=\"detail\">{profile_rows}</table>"
@@ -86,8 +146,30 @@ def render_project_detail(project: ProjectRecord) -> str:
f"<table class=\"detail\">{workflow_rows}</table>"
"<h3>Schema paths</h3>"
f"<table class=\"detail\">{schema_rows}</table>"
"<h3>Last seen health</h3>"
f"{health_html}"
"<h3>Onboarding checklist</h3>"
"<p class=\"meta\">Read-only MVP — complete these steps outside the UI.</p>"
"<p class=\"meta\">Read-only — complete these steps outside the UI.</p>"
f"{summary_html}"
f"{checklist_html}"
f"<p><a href=\"/api/v1/projects/{_escape(dto['id'])}\">JSON detail</a></p>"
)
return render_page(title=project.repo_name, body_html=body)
return render_page(title=dto["repo_name"], body_html=body)
def render_registry_error(error: RegistryError) -> str:
"""Render a fail-closed page for an invalid registry."""
source = str(error.source_path) if error.source_path else "unknown"
field = error.field_path or "n/a"
body = (
"<h2>Project registry unavailable</h2>"
"<p>The registry failed validation, so the console refuses to render a "
"partial inventory.</p>"
"<table class=\"detail\">"
f"<tr><th>Detail</th><td>{_escape(error.message)}</td></tr>"
f"<tr><th>Field</th><td><code>{_escape(field)}</code></td></tr>"
f"<tr><th>Source</th><td><code>{_escape(source)}</code></td></tr>"
f"<tr><th>Remediation</th><td>{_escape(error.remediation)}</td></tr>"
"</table>"
)
return render_page(title="Project registry unavailable", body_html=body)
-682
View File
@@ -1,682 +0,0 @@
"""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),
}