Merge branch 'master' into feat/issue-633-console-authz-audit-model
This commit is contained in:
+72
-5
@@ -12,8 +12,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
|
||||
@@ -85,14 +97,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(
|
||||
@@ -110,10 +134,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())
|
||||
|
||||
@@ -343,6 +404,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"]),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+460
-25
@@ -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
|
||||
return None
|
||||
|
||||
|
||||
def known_project_ids(registry: ProjectRegistry) -> list[str]:
|
||||
return [project.id for project in registry.projects]
|
||||
|
||||
+107
-25
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user