Add the declarative worker registry that epic #797 makes the source of truth for the scheduled multi-LLM worker fleet. Providers and configured workers are modelled as separate entities so a provider can be listed with no worker configured, and so provider facts are not copied into every worker record. A worker records provider, model, project, role, namespace, profile, workflow, schedule, timeout, enabled state, and scheduler metadata. Validation fails closed: unknown fields are refused rather than ignored, so a typo cannot silently disable a timeout; a worker naming an undeclared provider is rejected; worker ids, provider ids, and LaunchAgent labels must be unique. Persistence is atomic (temp file in the same directory, fsync, replace). Every superseded document is retained as a numbered revision, and rollback republishes a chosen revision as a new head, so history stays append-only and a rollback is itself reversible. The credential-rejection guard is extracted to webui/registry_safety.py so both registries share one implementation instead of two copies of a security check; project_registry.py keeps identical behaviour. Scope: data model, validation, persistence only. No routes, scheduler, process control, or provider probing - those are #799/#800/#804/#805. The workers array ships empty because populating it is #808. Tests: tests/test_webui_worker_registry.py, 44 cases. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
164 lines
5.2 KiB
Python
164 lines
5.2 KiB
Python
"""Load and validate the web UI project registry (#427)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from webui.registry_safety import reject_credential_keys as _reject_credential_keys
|
|
|
|
_REQUIRED_PROJECT_FIELDS = (
|
|
"id",
|
|
"repo_name",
|
|
"gitea_owner",
|
|
"remote_host",
|
|
"default_branch",
|
|
"local_checkout_path",
|
|
"profiles",
|
|
"workflow_paths",
|
|
)
|
|
|
|
_REQUIRED_PROFILE_ROLES = ("author", "reviewer", "reconciler")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class OnboardingStep:
|
|
id: str
|
|
title: str
|
|
description: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProjectRecord:
|
|
id: str
|
|
repo_name: str
|
|
gitea_owner: str
|
|
remote_host: str
|
|
default_branch: str
|
|
local_checkout_path: str
|
|
profiles: dict[str, str]
|
|
workflow_paths: dict[str, str]
|
|
schema_paths: dict[str, str]
|
|
onboarding_checklist: tuple[OnboardingStep, ...]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProjectRegistry:
|
|
version: int
|
|
projects: tuple[ProjectRecord, ...]
|
|
source_path: Path
|
|
|
|
|
|
def default_registry_path() -> Path:
|
|
override = os.environ.get("WEBUI_PROJECT_REGISTRY", "").strip()
|
|
if override:
|
|
return Path(override).expanduser().resolve()
|
|
return (Path(__file__).resolve().parent / "data" / "projects.registry.json").resolve()
|
|
|
|
|
|
def _parse_onboarding(raw: list[dict[str, Any]] | None) -> tuple[OnboardingStep, ...]:
|
|
if not raw:
|
|
return ()
|
|
steps: list[OnboardingStep] = []
|
|
for item in raw:
|
|
steps.append(
|
|
OnboardingStep(
|
|
id=str(item["id"]),
|
|
title=str(item["title"]),
|
|
description=str(item["description"]),
|
|
)
|
|
)
|
|
return tuple(steps)
|
|
|
|
|
|
def _parse_project(raw: dict[str, Any]) -> ProjectRecord:
|
|
missing = [field for field in _REQUIRED_PROJECT_FIELDS if field not in raw]
|
|
if missing:
|
|
raise ValueError(f"project missing required fields: {', '.join(missing)}")
|
|
|
|
profiles = raw["profiles"]
|
|
if not isinstance(profiles, dict):
|
|
raise ValueError("profiles must be an object")
|
|
for role in _REQUIRED_PROFILE_ROLES:
|
|
if role not in profiles or not profiles[role]:
|
|
raise ValueError(f"profiles.{role} is required")
|
|
|
|
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")
|
|
|
|
schema_paths = raw.get("schema_paths") or {}
|
|
if not isinstance(schema_paths, dict):
|
|
raise ValueError("schema_paths must be an object when present")
|
|
|
|
return ProjectRecord(
|
|
id=str(raw["id"]),
|
|
repo_name=str(raw["repo_name"]),
|
|
gitea_owner=str(raw["gitea_owner"]),
|
|
remote_host=str(raw["remote_host"]),
|
|
default_branch=str(raw["default_branch"]),
|
|
local_checkout_path=str(raw["local_checkout_path"]),
|
|
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")),
|
|
)
|
|
|
|
|
|
def load_registry(path: Path | None = None) -> ProjectRegistry:
|
|
"""Load the versioned project registry from disk."""
|
|
source = (path or default_registry_path()).resolve()
|
|
raw_text = source.read_text(encoding="utf-8")
|
|
payload = json.loads(raw_text)
|
|
if not isinstance(payload, dict):
|
|
raise ValueError("registry root must be an object")
|
|
|
|
version = payload.get("version")
|
|
if version != 1:
|
|
raise ValueError(f"unsupported registry version: {version!r}")
|
|
|
|
_reject_credential_keys(payload)
|
|
|
|
projects_raw = payload.get("projects")
|
|
if not isinstance(projects_raw, list) or not projects_raw:
|
|
raise ValueError("projects must be a non-empty array")
|
|
|
|
projects = tuple(_parse_project(item) for item in projects_raw)
|
|
return ProjectRegistry(version=version, projects=projects, source_path=source)
|
|
|
|
|
|
def project_to_dict(project: ProjectRecord) -> dict[str, Any]:
|
|
"""Serialize a project for JSON API responses."""
|
|
return {
|
|
"id": project.id,
|
|
"repo_name": project.repo_name,
|
|
"gitea_owner": project.gitea_owner,
|
|
"remote_host": project.remote_host,
|
|
"default_branch": project.default_branch,
|
|
"local_checkout_path": project.local_checkout_path,
|
|
"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}
|
|
for step in project.onboarding_checklist
|
|
],
|
|
}
|
|
|
|
|
|
def registry_to_dict(registry: ProjectRegistry) -> dict[str, Any]:
|
|
return {
|
|
"version": registry.version,
|
|
"source_path": str(registry.source_path),
|
|
"projects": [project_to_dict(project) for project in registry.projects],
|
|
}
|
|
|
|
|
|
def find_project(registry: ProjectRegistry, project_id: str) -> ProjectRecord | None:
|
|
for project in registry.projects:
|
|
if project.id == project_id:
|
|
return project
|
|
return None |