From b2f6e9a6dc40e9651ef876f322dd0a68bddebfd8 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Wed, 22 Jul 2026 16:12:07 -0400
Subject: [PATCH] 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)
---
docs/webui-local-dev.md | 10 +-
docs/webui-project-registry-api.md | 213 ++++++++++++
tests/test_webui_project_registry.py | 367 ++++++++++++++++++--
webui/app.py | 77 ++++-
webui/data/projects.registry.json | 22 +-
webui/project_registry.py | 485 +++++++++++++++++++++++++--
webui/project_views.py | 132 ++++++--
7 files changed, 1212 insertions(+), 94 deletions(-)
create mode 100644 docs/webui-project-registry-api.md
diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md
index a92e509..f5799b2 100644
--- a/docs/webui-local-dev.md
+++ b/docs/webui-local-dev.md
@@ -43,6 +43,10 @@ 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 |
@@ -51,9 +55,11 @@ that govern when a write path may open (#632, epic #631).
| `/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) |
diff --git a/docs/webui-project-registry-api.md b/docs/webui-project-registry-api.md
new file mode 100644
index 0000000..67f20ba
--- /dev/null
+++ b/docs/webui-project-registry-api.md
@@ -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.
diff --git a/tests/test_webui_project_registry.py b/tests/test_webui_project_registry.py
index f16774a..4d1c650 100644
--- a/tests/test_webui_project_registry.py
+++ b/tests/test_webui_project_registry.py
@@ -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",
+ }
+ )
+ ],
+ })
+ 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)
+ 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:
- json.dump(payload, handle)
+ handle.write("{not json")
path = Path(handle.name)
- try:
- with self.assertRaises(ValueError):
- load_registry(path)
- finally:
- path.unlink(missing_ok=True)
+ 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,31 +270,147 @@ 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__":
- unittest.main()
\ No newline at end of file
+ unittest.main()
diff --git a/webui/app.py b/webui/app.py
index d0f832b..8da3f7c 100644
--- a/webui/app.py
+++ b/webui/app.py
@@ -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
@@ -81,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(
@@ -106,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())
@@ -268,6 +329,12 @@ def create_app(*, bind_host: str | None = None) -> Starlette:
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"]),
diff --git a/webui/data/projects.registry.json b/webui/data/projects.registry.json
index cd6c852..04eca9c 100644
--- a/webui/data/projects.registry.json
+++ b/webui/data/projects.registry.json
@@ -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,24 +28,32 @@
{
"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
}
]
}
]
-}
\ No newline at end of file
+}
diff --git a/webui/project_registry.py b/webui/project_registry.py
index afc5e15..dc96ef9 100644
--- a/webui/project_registry.py
+++ b/webui/project_registry.py
@@ -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()
- raw_text = source.read_text(encoding="utf-8")
- payload = json.loads(raw_text)
+ 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
\ No newline at end of file
+ return None
+
+
+def known_project_ids(registry: ProjectRegistry) -> list[str]:
+ return [project.id for project in registry.projects]
diff --git a/webui/project_views.py b/webui/project_views.py
index c5076ef..4054eb4 100644
--- a/webui/project_views.py
+++ b/webui/project_views.py
@@ -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(
""
- f"| {_escape(project.repo_name)} | "
- f"{_escape(project.gitea_owner)} | "
- f"{_escape(project.remote_host)} | "
- f"{_escape(project.default_branch)} | "
- f"{_escape(project.profiles['author'])} | "
+ f"{_escape(dto['repo_name'])} | "
+ f"{_escape(dto['gitea_owner'])} | "
+ f"{_escape(dto['remote_host'])} | "
+ f"{_escape(dto['default_branch'])} | "
+ f"{_escape(dto['status'])} | "
+ f"{_escape(_progress_label(dto['onboarding_summary']))} | "
+ f"{_escape(dto['profiles']['author'])} | "
"
"
)
table = (
""
""
"| Repository | Owner | Remote | "
- "Branch | Author profile | "
+ "Branch | Status | Onboarding | Author profile | "
"
"
f"{''.join(rows)}
"
)
@@ -36,32 +67,39 @@ def render_projects_list(registry: ProjectRegistry) -> str:
"Projects
"
"Configured repositories managed by the MCP Control Plane.
"
f"Registry: {_escape(str(registry.source_path))} "
- f"(version {registry.version})
"
+ f"(schema version {registry.schema_version}, "
+ f"API {_escape(registry.api_version)})
"
f"{table}"
- "JSON API
"
+ "JSON API "
+ "(unversioned alias)
"
)
return render_page(title="Projects", body_html=body)
def render_project_detail(project: ProjectRecord) -> str:
+ dto = project_to_dict(project)
profile_rows = "".join(
f"| {_escape(role)} | {_escape(name)} |
"
- for role, name in project.profiles.items()
+ for role, name in dto["profiles"].items()
)
workflow_rows = "".join(
f"| {_escape(key)} | {_escape(path)} |
"
- for key, path in project.workflow_paths.items()
+ for key, path in dto["workflow_paths"].items()
)
schema_rows = "".join(
f"| {_escape(key)} | {_escape(path)} |
"
- 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(
- ""
- f"{index}. {_escape(step.title)}"
- f"{_escape(step.description)}
"
+ f""
+ f"{index}. {_escape(step['title'])}"
+ f" {_escape(state_label)}"
+ f" ({_escape(requirement)})"
+ f"{_escape(step['description'])}
"
""
)
checklist_html = (
@@ -69,16 +107,38 @@ def render_project_detail(project: ProjectRecord) -> str:
if checklist_items
else "No onboarding steps defined.
"
)
+ summary = dto["onboarding_summary"]
+ summary_html = (
+ "Onboarding: "
+ f"{_escape(_progress_label(summary))}; required outstanding "
+ f"{summary['required_outstanding']}.
"
+ )
+ health = dto["last_seen_health"]
+ health_html = (
+ "No health probe recorded (Phase 1 is read-only).
"
+ if health is None
+ else (
+ ""
+ f"| Status | {_escape(health['status'])} |
"
+ f"| Checked at | {_escape(str(health['checked_at'] or 'unknown'))} |
"
+ f"| Detail | {_escape(str(health['detail'] or ''))} |
"
+ "
"
+ )
+ )
+ remote_name = dto["remote_name"] or "unset"
body = (
- f"{_escape(project.repo_name)}
"
+ f"{_escape(dto['repo_name'])}
"
"← All projects
"
"Identity
"
""
- f"| Registry id | {_escape(project.id)} |
"
- f"| Gitea owner | {_escape(project.gitea_owner)} |
"
- f"| Remote host | {_escape(project.remote_host)} |
"
- f"| Default branch | {_escape(project.default_branch)} |
"
- f"| Local checkout | {_escape(project.local_checkout_path)} |
"
+ f"| Registry id | {_escape(dto['id'])} |
"
+ f"| Status | {_escape(dto['status'])} |
"
+ f"| Gitea owner | {_escape(dto['gitea_owner'])} |
"
+ f"| Repository | {_escape(dto['repo_full_name'])} |
"
+ f"| Remote name | {_escape(remote_name)} |
"
+ f"| Remote host | {_escape(dto['remote_host'])} |
"
+ f"| Default branch | {_escape(dto['default_branch'])} |
"
+ f"| Local checkout | {_escape(dto['local_checkout_path'])} |
"
"
"
"Profiles
"
f""
@@ -86,8 +146,30 @@ def render_project_detail(project: ProjectRecord) -> str:
f""
"Schema paths
"
f""
+ "Last seen health
"
+ f"{health_html}"
"Onboarding checklist
"
- "Read-only MVP — complete these steps outside the UI.
"
+ "Read-only — complete these steps outside the UI.
"
+ f"{summary_html}"
f"{checklist_html}"
+ f"JSON detail
"
)
- return render_page(title=project.repo_name, body_html=body)
\ No newline at end of file
+ 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 = (
+ "Project registry unavailable
"
+ "The registry failed validation, so the console refuses to render a "
+ "partial inventory.
"
+ ""
+ f"| Detail | {_escape(error.message)} |
"
+ f"| Field | {_escape(field)} |
"
+ f"| Source | {_escape(source)} |
"
+ f"| Remediation | {_escape(error.remediation)} |
"
+ "
"
+ )
+ return render_page(title="Project registry unavailable", body_html=body)