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]>
7.9 KiB
Project registry API (#635)
Phase 1 of the console architecture ADR 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:
{
"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}:
{
"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:
{
"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
projectsmissing/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/reconcilerprofile; - an unknown
status, onboardingstate, or healthstatus.
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 and
credential-isolation.md. Tokens live in the keychain
and are resolved server-side by gitea_auth.
Migrating a version 1 registry
- Set
"version": 2. - Optionally add
"status"per project (omitted meansactive). - Optionally add
"remote_name"per project. - Optionally add
"state"and"required"to each onboarding step (omitted meanspendingandtrue). - 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.