Files
GEWU/src/gewu/application/registry.py
T
2026-10-14 19:21:35 +08:00

427 lines
18 KiB
Python

"""Local project/policy configuration and committed-record context provider."""
from __future__ import annotations
import json
from contextlib import AbstractContextManager
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import portalocker
import yaml
from pydantic import BaseModel, ValidationError
from gewu.application.context import ValidationContext
from gewu.application.paths import reject_symlink_components
from gewu.application.ports import CanonicalConflictError, CanonicalRepository
from gewu.application.serialization import safe_yaml_record
from gewu.domain.contracts import (
AutoAcceptPolicy,
ExtractionRecord,
KnowledgeNode,
ProjectRecord,
ProjectStatus,
SourceRevisionRecord,
)
from gewu.domain.validation import extraction_digest
MAX_CONTEXT_RECORDS = 20_000
MAX_CONTEXT_RECORD_BYTES = 1_048_576
MAX_PROJECT_REGISTRY_RESPONSE_BYTES = 1_048_576
class RegistryError(RuntimeError):
"""Configured or committed context is malformed; acceptance must stop."""
class RegistryConflict(RegistryError):
"""A registry write conflicts with current project or policy identity."""
class RegistryNotFound(RegistryError):
"""A requested project is not registered."""
def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
result: dict[str, object] = {}
for key, value in pairs:
if key in result:
raise ValueError("duplicate JSON key")
result[key] = value
return result
def _records[RecordT: BaseModel](
raw: bytes, field: str, record_type: type[RecordT]
) -> tuple[RecordT, ...]:
if len(raw) > MAX_CONTEXT_RECORD_BYTES:
raise RegistryError("Registry configuration exceeds the supported size")
try:
value = json.loads(raw, object_pairs_hook=_unique_object)
if (
not isinstance(value, dict)
or set(value) != {"schema_version", "projects", "policies"}
or value.get("schema_version") != 1
):
raise ValueError("registry schema version or fields are invalid")
records = value[field]
if not isinstance(records, list):
raise ValueError("registry field must be a list")
return tuple(
record_type.model_validate_json(json.dumps(item, ensure_ascii=False), strict=True)
for item in records
)
except (KeyError, TypeError, ValueError, json.JSONDecodeError, ValidationError) as error:
raise RegistryError("Registry configuration failed validation") from error
def _index[RecordT: BaseModel](records: tuple[RecordT, ...], field: str) -> dict[str, RecordT]:
result: dict[str, RecordT] = {}
for record in records:
key = getattr(record, field)
if key in result:
raise RegistryError("Registry contains duplicate stable IDs")
result[key] = record
return result
def _canonical_records[RecordT: BaseModel](
repository: CanonicalRepository,
revision: str,
prefix: str,
record_type: type[RecordT],
id_field: str,
) -> dict[str, RecordT]:
records: dict[str, RecordT] = {}
for record_path in repository.list_files(revision, prefix):
if not record_path.endswith(".yaml"):
continue
if prefix == "projects" and "/nodes/" not in record_path:
continue
raw = repository.read_file(revision, record_path)
if raw is None or len(raw) > MAX_CONTEXT_RECORD_BYTES:
raise RegistryError("Canonical context record is unavailable or too large")
record = safe_yaml_record(raw, record_type)
record_id = getattr(record, id_field)
if record_id in records:
raise RegistryError("Canonical repository contains duplicate stable IDs")
records[record_id] = record
if len(records) > MAX_CONTEXT_RECORDS:
raise RegistryError("Canonical context exceeds the configured record limit")
return records
@dataclass(frozen=True, slots=True)
class LocalContextProvider:
repository: CanonicalRepository
registry_file: Path
def __call__(self) -> ValidationContext:
path = self.registry_file.expanduser().absolute()
try:
reject_symlink_components(path)
except ValueError as error:
raise RegistryError("Local registry path traverses a symbolic link") from error
if path.exists() and not path.is_file():
raise RegistryError("Local registry file is unavailable")
if not path.exists():
projects: dict[str, ProjectRecord] = {}
policies: dict[str, AutoAcceptPolicy] = {}
else:
try:
registry_bytes = path.read_bytes()
except OSError as error:
raise RegistryError("Local registry file is unavailable") from error
projects = _index(_records(registry_bytes, "projects", ProjectRecord), "project_id")
policies = _index(_records(registry_bytes, "policies", AutoAcceptPolicy), "policy_id")
revision = self.repository.head()
projects.update(_canonical_projects(self.repository, revision))
policies.update(
_canonical_records(
self.repository,
revision,
"policies",
AutoAcceptPolicy,
"policy_id",
)
)
sources = _canonical_records(
self.repository, revision, "sources", SourceRevisionRecord, "revision_id"
)
nodes = _canonical_records(self.repository, revision, "projects", KnowledgeNode, "node_id")
extractions: dict[str, ExtractionRecord] = {}
record_count = 0
try:
record_count += len(sources) + len(nodes)
for record_path in self.repository.list_files(revision, "extractions"):
if not record_path.endswith(".json"):
continue
record_count += 1
extraction_bytes = self.repository.read_file(revision, record_path)
if extraction_bytes is None or len(extraction_bytes) > MAX_CONTEXT_RECORD_BYTES:
raise RegistryError("Canonical extraction is unavailable or too large")
extraction = ExtractionRecord.model_validate_json(extraction_bytes, strict=True)
if extraction_digest(extraction) != extraction.extraction_id:
raise RegistryError("Canonical extraction identity is invalid")
if extraction.extraction_id in extractions:
raise RegistryError("Canonical repository contains duplicate extraction IDs")
extractions[extraction.extraction_id] = extraction
except (OSError, ValueError, ValidationError) as error:
raise RegistryError("Canonical context failed validation") from error
if record_count > MAX_CONTEXT_RECORDS:
raise RegistryError("Canonical context exceeds the configured record limit")
return ValidationContext(
projects=projects,
policies=policies,
sources=sources,
extractions=extractions,
existing_nodes=nodes,
)
@dataclass(frozen=True, slots=True)
class LocalProjectRegistry:
"""Read legacy control entries and canonical project lifecycle records."""
registry_file: Path
repository: CanonicalRepository | None = None
index_directory: Path | None = None
authority_lock_path: Path | None = None
def _legacy_projects(self) -> tuple[ProjectRecord, ...]:
path = self.registry_file.expanduser().absolute()
try:
reject_symlink_components(path)
except ValueError as error:
raise RegistryError("Local registry path traverses a symbolic link") from error
if not path.exists():
return ()
if path.is_symlink() or not path.is_file():
raise RegistryError("Local registry file is unavailable")
try:
raw = path.read_bytes()
except OSError as error:
raise RegistryError("Local registry file is unavailable") from error
return _records(raw, "projects", ProjectRecord)
def _projects(self) -> tuple[ProjectRecord, ...]:
projects = _index(self._legacy_projects(), "project_id")
if self.repository is not None:
projects.update(_canonical_projects(self.repository, self.repository.head()))
return tuple(projects.values())
def _policies(self) -> tuple[AutoAcceptPolicy, ...]:
path = self.registry_file.expanduser().absolute()
if not path.exists():
policies: dict[str, AutoAcceptPolicy] = {}
elif path.is_symlink() or not path.is_file():
raise RegistryError("Local registry file is unavailable")
else:
try:
raw = path.read_bytes()
except OSError as error:
raise RegistryError("Local registry file is unavailable") from error
policies = _index(_records(raw, "policies", AutoAcceptPolicy), "policy_id")
if self.repository is not None:
policies.update(
_canonical_records(
self.repository,
self.repository.head(),
"policies",
AutoAcceptPolicy,
"policy_id",
)
)
return tuple(policies.values())
def __call__(self) -> frozenset[str]:
return frozenset(_index(self._projects(), "project_id"))
def records(self) -> tuple[ProjectRecord, ...]:
records = tuple(sorted(self._projects(), key=lambda project: project.project_id))
response_bytes = sum(
len(
json.dumps(
project.model_dump(mode="json"),
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8")
)
for project in records
)
if response_bytes > MAX_PROJECT_REGISTRY_RESPONSE_BYTES:
raise RegistryError("Project registry listing exceeds the supported size")
return records
def create(self, project: ProjectRecord, policy: AutoAcceptPolicy) -> ProjectRecord:
"""Atomically add a project and its explicitly supplied restrictive policy."""
if project.project_id == "shared" or project.status != ProjectStatus.ACTIVE:
raise ValueError("New projects must use a non-reserved ID and active status")
if not project.owners:
raise ValueError("At least one named project owner is required")
if (
project.autonomy_policy_id != policy.policy_id
or policy.project_id != project.project_id
):
raise ValueError("Project and initial policy identities must match")
if not policy.require_supported_assessment:
raise ValueError("Project policies must require supported assessments")
if (
self.repository is None
or self.index_directory is None
or self.authority_lock_path is None
):
raise RegistryError("Canonical project publication is not configured")
with self._write_lock():
project_index = _index(self._projects(), "project_id")
policy_index = _index(self._policies(), "policy_id")
if project.project_id in project_index:
raise RegistryConflict("Project ID is already registered")
if policy.policy_id in policy_index:
raise RegistryConflict("Policy ID is already registered")
available_projects = set(project_index) | {project.project_id, "shared"}
if any(item not in available_projects for item in project.default_includes):
raise ValueError("Project default includes must reference registered projects")
if project.project_id in project.default_includes:
raise ValueError("A project cannot include itself by default")
try:
self.repository.publish_files(
expected_head=self.repository.head(),
files={
_project_record_path(project.project_id): _project_markdown(project),
_policy_record_path(policy.policy_id): _yaml_record(policy),
},
message=f"Register project {project.project_id}",
index_directory=self.index_directory,
)
except CanonicalConflictError as error:
raise RegistryConflict(
"Canonical project registry changed during creation"
) from error
return project
def update_content(
self, project_id: str, *, description: str, north_star: str, key_questions: tuple[str, ...]
) -> ProjectRecord:
"""Update a project's human-authored description and research questions."""
if self.repository is None or self.index_directory is None or self.authority_lock_path is None:
raise RegistryError("Canonical project publication is not configured")
with self._write_lock():
project = _index(self._projects(), "project_id").get(project_id)
if project is None:
raise RegistryNotFound("Project is not registered")
updated = project.model_copy(update={
"description": description, "north_star": north_star, "key_questions": key_questions,
})
try:
self.repository.publish_files(
expected_head=self.repository.head(),
files={_project_record_path(project_id): _project_markdown(updated)},
message=f"Update project description and questions for {project_id}",
index_directory=self.index_directory,
)
except CanonicalConflictError as error:
raise RegistryConflict("Canonical project registry changed during update") from error
return updated
def archive(self, project_id: str) -> ProjectRecord:
"""Mark a project archived while preserving its registry and read history."""
if project_id == "shared":
raise ValueError("The shared scope cannot be archived")
if (
self.repository is None
or self.index_directory is None
or self.authority_lock_path is None
):
raise RegistryError("Canonical project publication is not configured")
with self._write_lock():
project_index = _index(self._projects(), "project_id")
project = project_index.get(project_id)
if project is None:
raise RegistryNotFound("Project is not registered")
if project.status == ProjectStatus.ARCHIVED:
return project
archived = project.model_copy(update={"status": ProjectStatus.ARCHIVED})
try:
self.repository.publish_files(
expected_head=self.repository.head(),
files={_project_record_path(project_id): _project_markdown(archived)},
message=f"Archive project {project_id}",
index_directory=self.index_directory,
)
except CanonicalConflictError as error:
raise RegistryConflict(
"Canonical project registry changed during archive"
) from error
return archived
def _write_lock(self) -> AbstractContextManager[Any]:
lock_path = self.authority_lock_path
if lock_path is None:
lock_path = self.registry_file.with_suffix(self.registry_file.suffix + ".lock")
lock_path = lock_path.expanduser().absolute()
reject_symlink_components(lock_path)
lock_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
lock_path.parent.chmod(0o700)
return portalocker.Lock(lock_path, mode="a", timeout=0)
def default_includes(self, project_id: str) -> tuple[str, ...]:
projects = _index(self._projects(), "project_id")
project = projects.get(project_id)
if project is None:
raise RegistryError("Project is unavailable for default scope resolution")
return tuple(include for include in project.default_includes if include != "shared")
def _project_record_path(project_id: str) -> str:
return f"projects/{project_id}/project.md"
def _policy_record_path(policy_id: str) -> str:
import hashlib
digest = hashlib.sha256(policy_id.encode("utf-8")).hexdigest()
return f"policies/{digest}.yaml"
def _yaml_record(record: BaseModel) -> bytes:
return yaml.safe_dump(
record.model_dump(mode="json"),
allow_unicode=True,
sort_keys=True,
).encode("utf-8")
def _project_markdown(project: ProjectRecord) -> bytes:
return b"---\n" + _yaml_record(project) + b"---\n"
def _canonical_projects(repository: CanonicalRepository, revision: str) -> dict[str, ProjectRecord]:
projects: dict[str, ProjectRecord] = {}
total_bytes = 0
for record_path in repository.list_files(revision, "projects"):
if not record_path.endswith("/project.md"):
continue
raw = repository.read_file(revision, record_path)
if raw is None or len(raw) > MAX_CONTEXT_RECORD_BYTES:
raise RegistryError("Canonical project record is unavailable or too large")
total_bytes += len(raw)
if total_bytes > MAX_PROJECT_REGISTRY_RESPONSE_BYTES:
raise RegistryError("Canonical project registry exceeds the supported size")
if not raw.startswith(b"---\n") or b"\n---\n" not in raw[4:]:
raise RegistryError("Canonical project frontmatter is invalid")
frontmatter = raw[4:].split(b"\n---\n", maxsplit=1)[0]
project = safe_yaml_record(frontmatter, ProjectRecord)
if record_path != _project_record_path(project.project_id):
raise RegistryError("Canonical project path does not match its stable ID")
if project.project_id in projects:
raise RegistryError("Canonical repository contains duplicate project IDs")
projects[project.project_id] = project
if len(projects) > MAX_CONTEXT_RECORDS:
raise RegistryError("Canonical project registry exceeds the supported record count")
return projects