feat(webui): versioned project registry API (Closes #635)
Evolve the MVP project registry (#427) into a versioned, fail-closed project registry API for the console (Phase 1, read-only). - Add schema version 2 with project `status`, per-step onboarding `state`/`required`, optional redacted `last_seen_health`, and `remote_name`. Version 1 files stay loadable and are normalized with explicit defaults. - Serve `/api/v1/projects` and `/api/v1/projects/{project_id}` with API provenance (`api_version`, `schema_version`, `source`). `/api/projects` is retained as an unversioned Phase 1 alias. - Replace bare `ValueError` with `RegistryError`, carrying an operator `remediation` and `field_path`; invalid registries fail closed as a 500 JSON payload or a dedicated HTML error page instead of a traceback. - Reject credential-shaped keys before any DTO is built, reusing `registry_safety.is_forbidden_key` as the single source of truth shared with the worker registry (#798). - Render HTML views from `project_to_dict`, so the console and the JSON API cannot disagree about status or onboarding progress. - Document the contract in docs/webui-project-registry-api.md. Tests: registry load/validate (valid, missing project, schema validation, credential rejection) and API route coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
+72
-5
@@ -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"]),
|
||||
|
||||
Reference in New Issue
Block a user