§22 S2: registry mirror reads .collection.yaml manifests

Discover named collections by walking each project's content-repo root for
<subdir>/.collection.yaml; parse + upsert with immutable-type enforcement
(§22.4a) and project-visibility inheritance. The default collection still
flows from projects.yaml.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-05 12:59:25 -07:00
parent 599e7018f6
commit 74476423ba
2 changed files with 251 additions and 0 deletions
+105
View File
@@ -55,6 +55,17 @@ class ProjectEntry:
config: dict = field(default_factory=dict) # theme, enabled_models
@dataclass
class CollectionEntry:
"""A named collection declared by a `.collection.yaml` manifest inside a
project's content repo (S2). `visibility=None` means "inherit the project's
visibility"."""
type: str
visibility: str | None
initial_state: str
name: str | None
@dataclass
class RegistryDoc:
deployment_name: str
@@ -114,6 +125,33 @@ def parse_registry(text: str) -> RegistryDoc:
)
def parse_collection_manifest(text: str) -> CollectionEntry:
"""Parse + validate a `.collection.yaml`. Pure (no I/O). Raises RegistryError.
`type` is required and immutable (§22.4a, enforced at upsert). `visibility`
is optional — omitted means inherit the project's. `initial_state` defaults
per type (§22.4b)."""
raw = yaml.safe_load(text) or {}
if not isinstance(raw, dict):
raise RegistryError("collection manifest must be a mapping")
ctype = str(raw.get("type") or "").strip()
if ctype not in VALID_TYPES:
raise RegistryError(f"collection has invalid type {ctype!r}")
vis = raw.get("visibility")
if vis is not None:
vis = str(vis).strip()
if vis not in VALID_VISIBILITY:
raise RegistryError(f"collection has invalid visibility {vis!r}")
initial_state = str(
raw.get("initial_state") or _TYPE_DEFAULT_INITIAL_STATE[ctype]
).strip()
if initial_state not in VALID_INITIAL_STATE:
raise RegistryError(f"collection has invalid initial_state {initial_state!r}")
name = raw.get("name")
name = str(name).strip() if name else None
return CollectionEntry(ctype, vis, initial_state, name)
def _default_collection_id(project_id: str, default_id: str) -> str:
"""The id of a project's default collection. The deployment's primary
project (== `default_id`, the §22.13 resolved default) gets the stable
@@ -193,6 +231,71 @@ def apply_registry(doc: RegistryDoc, registry_sha: str, default_id: str) -> None
)
def _upsert_named_collection(
proj: ProjectEntry, subdir: str, ce: CollectionEntry, sha: str
) -> None:
"""Upsert one named collection (S2). Type is immutable (§22.4a): a type
change against an existing row is refused (logged, not applied). A None
manifest visibility inherits the project's visibility."""
visibility = ce.visibility or proj.visibility
with db.tx() as conn:
existing = conn.execute(
"SELECT type FROM collections WHERE id = ?", (subdir,)
).fetchone()
if existing is not None and existing["type"] != ce.type:
log.error(
"registry: refusing immutable type change on collection %s (%s -> %s)",
subdir, existing["type"], ce.type,
)
return
conn.execute(
"""
INSERT INTO collections
(id, project_id, type, subfolder, initial_state, visibility, name, registry_sha, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(id) DO UPDATE SET
project_id = excluded.project_id,
initial_state = excluded.initial_state,
visibility = excluded.visibility,
name = excluded.name,
registry_sha = excluded.registry_sha,
updated_at = datetime('now')
""",
(subdir, proj.id, ce.type, subdir, ce.initial_state, visibility, ce.name, sha),
)
async def _mirror_named_collections(config: Config, gitea: Gitea, doc: RegistryDoc, sha: str) -> None:
"""§22 S2: named collections are declared by `.collection.yaml` manifests
inside each project's content repo (the default collection comes from
projects.yaml). Walk each content repo root; a subdir carrying a manifest
becomes a collection keyed by the subdir name. Tolerant: a transport or
parse failure on one project/collection logs and is skipped, never aborts
the wider mirror (keep last-good)."""
for proj in doc.projects:
try:
items = await gitea.list_dir(config.gitea_org, proj.content_repo, "", ref="main")
except Exception as e: # noqa: BLE001 — GiteaError/transport: tolerate
log.warning("registry: cannot list %s root: %s", proj.content_repo, e)
continue
for it in items:
if it.get("type") != "dir":
continue
subdir = it["name"]
manifest = await gitea.get_contents(
config.gitea_org, proj.content_repo, f"{subdir}/.collection.yaml", ref="main"
)
if not manifest or manifest.get("type") != "file":
continue
mtext = base64.b64decode(manifest["content"]).decode("utf-8")
try:
ce = parse_collection_manifest(mtext)
except RegistryError as e:
log.error("registry: bad manifest %s/%s: %s", proj.content_repo, subdir, e)
continue
_upsert_named_collection(proj, subdir, ce, sha)
async def refresh_registry(config: Config, gitea: Gitea) -> None:
"""Mirror REGISTRY_REPO/projects.yaml into projects + deployment.
@@ -213,4 +316,6 @@ async def refresh_registry(config: Config, gitea: Gitea) -> None:
doc = parse_registry(text)
from . import projects as projects_mod
apply_registry(doc, sha, projects_mod.resolved_default_id(config))
# §22 S2: discover + upsert named collections from each content repo.
await _mirror_named_collections(config, gitea, doc, sha)
log.info("registry: mirrored %d project(s) at %s", len(doc.projects), sha)