diff --git a/docs/registry/cache.md b/docs/registry/cache.md new file mode 100644 index 0000000..899cf88 --- /dev/null +++ b/docs/registry/cache.md @@ -0,0 +1,248 @@ +# RegistryCache + +Transparent client-side LRU cache with TTL expiration and SHA-1 tamper detection, +implementing [Package Registry Standard v1.0.0](../actor-registry-standard.md) §10.3. + +```python +from cleveractors.registry import RegistryCache, CacheFactory, CacheStats +``` + +--- + +## Overview + +`RegistryCache` wraps a `RegistryClient` and adds: + +| Feature | Mechanism | +|---------|-----------| +| **LRU eviction** | Least-recently-used entry evicted when `max_size` exceeded | +| **TTL expiration** | Entries older than `ttl` seconds are re-fetched | +| **SHA-1 tamper detection** | Cached content hash is validated against stored PackageId | +| **Singleflight coalescing** | Concurrent cold-key requests share a single upstream fetch | +| **Thread safety** | All operations protected by `asyncio.Lock` | + +--- + +## Constructor + +```python +RegistryCache( + client: RegistryClient, + *, + max_size: int = 256, + ttl: float = 300.0, + validate_content: bool = True, +) +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `client` | `RegistryClient` | *required* | The upstream client to cache responses for | +| `max_size` | `int` | `256` | Maximum number of cached entries before LRU eviction | +| `ttl` | `float` | `300.0` | Time-to-live in seconds before re-fetch | +| `validate_content` | `bool` | `True` | Whether to validate cached content by recomputing SHA-1 | + +--- + +## CacheStats + +Observable cache statistics exposed via `RegistryCache.stats`: + +```python +@dataclass +class CacheStats: + hits: int = 0 + misses: int = 0 + evictions: int = 0 + + def record_hit(self) -> None: ... + def record_miss(self) -> None: ... + def record_eviction(self) -> None: ... + def reset(self) -> None: ... +``` + +```python +cache = RegistryCache(client, max_size=512, ttl=600.0) +# ... perform operations ... +print(f"hits={cache.stats.hits} misses={cache.stats.misses} evictions={cache.stats.evictions}") +``` + +--- + +## Methods + +### get_package + +```python +async def get_package(self, package_id: str) -> dict[str, Any] +``` + +Retrieve package content, using cache when available and valid. + +Cache-hit flow: +1. Entry found in cache and within TTL → validate SHA-1 (CPU-bound, outside lock). +2. Hash matches → return cached content. +3. Hash mismatch → evict (tampered), re-fetch from upstream. + +Cache-miss flow: +1. Entry missing, expired, or tampered. +2. Concurrent callers for the same key share a single upstream fetch (singleflight). +3. Fetch from upstream, populate cache, return. + +### resolve_package + +```python +async def resolve_package( + self, + package_type: str, + namespace: str, + name: str, + version: str = "latest", +) -> dict[str, Any] +``` + +Mirrors `RegistryClient.resolve_package` with transparent caching. +Maintains a separate resolve-store keyed by `(type, namespace, name, version)`. + +### put + +```python +async def put(self, package_id: str, content: dict[str, Any]) -> None +``` + +Insert content directly into the cache without an upstream fetch. +Useful when content was obtained through another channel (e.g. `resolve_package`) +and you want to warm the cache for subsequent `get_package` hits. + +### invalidate + +```python +async def invalidate(self, package_id: str) -> bool +``` + +Remove a specific entry from the cache. Returns `True` if the entry was present. + +```python +removed = await cache.invalidate("pkg_act_...") +print(f"Cache entry removed: {removed}") +``` + +### clear + +```python +async def clear(self) -> None +``` + +Clear all entries from both stores (package content and resolution) and reset +statistics. + +### close + +```python +async def close(self) -> None +``` + +Clear caches, reset statistics, and close the underlying `RegistryClient`. +If you need post-mortem statistics, snapshot `cache.stats` before calling `close()`. + +### `__contains__` + +```python +def __contains__(self, package_id: str) -> bool +``` + +Raw membership test; returns `True` if the key is present (does **not** check TTL). + +```python +if "pkg_act_..." in cache: + print("Entry exists in cache (may be expired)") +``` + +--- + +## Async Context Manager + +```python +async with RegistryCache(client, max_size=512) as cache: + content = await cache.get_package("pkg_act_...") + print(f"Final stats: hits={cache.stats.hits}") +# cache.close() called automatically +``` + +--- + +## CacheFactory + +Encapsulates cache configuration so consumers depend on the factory +abstraction rather than constructing `RegistryCache` directly. + +```python +factory = CacheFactory(max_size=1000, ttl=900.0, validate_content=True) +cache = factory.create(client) +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `max_size` | `int` | `256` | Maximum cache entries | +| `ttl` | `float` | `300.0` | TTL in seconds | +| `validate_content` | `bool` | `True` | Enable SHA-1 validation | + +--- + +## Real-Life Example: Email Categorization with Caching + +A categorization system that resolves templates once, caches them, and +detects tampered cache entries: + +```python +import asyncio +from cleveractors.registry import ( + RegistryClient, + RegistryCache, + CacheStats, + PackageId, +) + + +async def categorize_emails() -> None: + client = RegistryClient( + base_url="https://registry.example.com", + api_key="categorizer-key", + ) + cache = RegistryCache(client, max_size=256, ttl=600.0) + + CATEGORY_TEMPLATES = [ + "pkg_skl_aaa1111111111111111111111111111111111", + "pkg_skl_bbb2222222222222222222222222222222222", + "pkg_skl_ccc3333333333333333333333333333333333", + ] + + templates: dict[str, dict] = {} + + for pkg_id in CATEGORY_TEMPLATES: + try: + content = await cache.get_package(pkg_id) + templates[pkg_id] = content + print(f"Loaded template: {content.get('name', pkg_id)}") + except Exception as exc: + print(f"Failed to load {pkg_id}: {exc}") + + # Warm the cache explicitly via put (content from resolve) + resolved = await cache.resolve_package( + package_type="skill", + namespace="acme", + name="priority-classifier", + version="v2.0.0", + ) + await cache.put(resolved["package_id"], {"name": "priority-classifier"}) + + # Check cache statistics + print(f"Cache stats: {cache.stats}") + print(f"Template count: {len(templates)}") + + await cache.close() + + +if __name__ == "__main__": + asyncio.run(categorize_emails()) +``` \ No newline at end of file diff --git a/docs/registry/canonical.md b/docs/registry/canonical.md new file mode 100644 index 0000000..a0884e5 --- /dev/null +++ b/docs/registry/canonical.md @@ -0,0 +1,207 @@ +# Canonicalizer + +Deterministic RFC-8785 canonical JSON for content-addressed SHA-1 hashing, +implementing [Package Registry Standard v1.0.0](../actor-registry-standard.md) §6. + +```python +from cleveractors.registry import Canonicalizer +``` + +--- + +## Purpose + +The Canonicalizer produces a **deterministic, reproducible** JSON representation +of a package document so that identical content always produces the same SHA-1 +hash — and therefore the same Package ID. This enables content-addressed +storage, deduplication, and tamper detection. + +!!! warning "SHA-1 is for content-addressing only" + SHA-1 is used **solely** for content-addressing per §6. It is **not** + suitable for cryptographic integrity verification. Use a modern hash + (SHA-256, SHA-3) when cryptographic guarantees are required. + +--- + +## Constructor + +```python +Canonicalizer( + *, + reference_resolver: Callable[[str], str] | None = None, + max_depth: int = 100, +) +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `reference_resolver` | `Callable \| None` | `None` | Optional callback to resolve `ID:pkg_...` and `local:...` references before canonicalization | +| `max_depth` | `int` | `100` | Maximum nesting depth; documents exceeding this raise `ValueError` | + +When `reference_resolver` is configured, the canonicalizer walks the content +and replaces reference strings with their resolved concrete forms before +computing the canonical representation. + +--- + +## Methods + +### canonicalize + +```python +def canonicalize(self, content: dict[str, Any]) -> str +``` + +Produce deterministic RFC-8785 canonical JSON from a package content dict. + +The canonical form is produced by a 5-step pipeline (§6.2): + +1. **Reference resolution** (when a resolver callback is configured): resolve all + `ID:pkg_...` and `local:...` references to concrete forms. +2. **NFC normalization**: every string value is normalised to Unicode NFC form. +3. **Dictionary key sorting**: keys are sorted lexicographically. +4. **Lifecycle field stripping**: `version` and `release_date` keys are removed. +5. **RFC-8785 serialization**: sorted keys, UTF-8, no insignificant whitespace, + no solidus escaping, `NaN`/`Infinity` rejected. + +```python +canon = Canonicalizer() +json_str = canon.canonicalize({ + "name": "my-actor", + "version": "v1.0.0", # stripped + "release_date": "2025-01-01", # stripped + "agents": { + "chat": {"type": "llm"} + }, +}) +print(json_str) +# {"agents":{"chat":{"type":"llm"}},"name":"my-actor"} +``` + +### compute_package_id + +```python +def compute_package_id( + self, content: dict[str, Any], package_type: PackageType +) -> PackageId +``` + +Compute a content-addressed PackageId from content. + +1. Canonicalize the content. +2. Compute SHA-1 of the canonical JSON. +3. Format as `pkg__<40-hex-sha1>`. + +```python +from cleveractors.registry import Canonicalizer, PackageType, PackageId + +canon = Canonicalizer() +pid = canon.compute_package_id( + content={"name": "hello-world", "agents": {"echo": {"type": "tool"}}}, + package_type=PackageType.ACTOR, +) +print(pid.id_string) +# pkg_act_<40-hex-sha1> +``` + +### resolve_references + +```python +def resolve_references( + self, content: dict[str, Any] +) -> dict[str, Any] +``` + +Walk a content dict and resolve all internal `ID:pkg_...` and `local:...` +references via the configured resolver callback. Returns a new dict with all +references replaced by their resolved values. + +--- + +## Transformation Details + +### Lifecycle Fields + +The following keys are stripped during canonicalization because they record +*when* content was authored, not *what* the content is: + +- `version` — the version string assigned at publish time +- `release_date` — the timestamp of publication + +These fields are intentionally excluded so that publishing the same content +under a different version does not produce a different Package ID. + +### NFC Normalization + +All string values are normalized to Unicode Normalization Form C (NFC). +This ensures that visually identical strings with different Unicode +representations (e.g. composed vs. decomposed characters) produce the same +canonical form. + +### Float Handling + +IEEE 754 negative zero (`-0.0`) is normalized to `0.0` so that mathematically +equivalent values produce identical hashes. `NaN` and `Infinity` are rejected +by the RFC-8785 serializer (`allow_nan=False`). + +### Max Depth Protection + +Documents exceeding `max_depth` nesting raise `ValueError`. The default limit +of 100 protects against stack overflow on deeply nested or malicious documents. + +--- + +## Real-Life Example: CI/CD Actor Verification + +Verifying that an actor configuration has not drifted between CI/CD environments +by comparing content-addressed hashes: + +```python +import hashlib +from cleveractors.registry import Canonicalizer, PackageType + +def verify_actor_drift( + staging_config: dict, + production_config: dict, +) -> bool: + """ + Compare actor configs across environments using content-addressed hashes. + Returns True if identical, False if drift detected. + """ + canon = Canonicalizer() + + staging_json = canon.canonicalize(staging_config) + prod_json = canon.canonicalize(production_config) + + staging_hash = hashlib.sha1( + staging_json.encode("utf-8"), usedforsecurity=False + ).hexdigest() + prod_hash = hashlib.sha1( + prod_json.encode("utf-8"), usedforsecurity=False + ).hexdigest() + + if staging_hash != prod_hash: + pid_staging = canon.compute_package_id( + staging_config, PackageType.ACTOR + ) + pid_prod = canon.compute_package_id( + production_config, PackageType.ACTOR + ) + print( + f"DRIFT DETECTED:\n" + f" staging: {pid_staging.id_string}\n" + f" production: {pid_prod.id_string}" + ) + return False + + print("No drift detected — configurations are identical.") + return True +``` + +This pattern is particularly useful in CI/CD pipelines where actor +configurations are deployed across multiple environments and must remain +consistent: +- Compute the canonical hash of the config committed to the repository. +- After deployment, fetch the live config from each environment. +- Recompute and compare hashes to detect unauthorized modifications or + configuration drift. \ No newline at end of file diff --git a/docs/registry/client.md b/docs/registry/client.md new file mode 100644 index 0000000..e4696c4 --- /dev/null +++ b/docs/registry/client.md @@ -0,0 +1,263 @@ +# RegistryClient + +Async HTTP client implementing all 4 API endpoints of the +[Package Registry Standard v1.0.0](../actor-registry-standard.md) §8. + +```python +from cleveractors.registry import RegistryClient +``` + +--- + +## Constructor + +```python +RegistryClient( + base_url: str, + api_key: Optional[str] = None, + timeout: float = 30.0, + allow_insecure: bool = False, +) +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `base_url` | `str` | *required* | Registry server base URL (e.g. `"https://registry.example.com"`) | +| `api_key` | `str \| None` | `None` | Optional API key for authenticated operations (§9.2) | +| `timeout` | `float` | `30.0` | Request timeout in seconds | +| `allow_insecure` | `bool` | `False` | Suppress the HTTP-without-TLS security warning | + +The constructor emits a warning when an API key is used over HTTP without +`allow_insecure=True`, because the standard requires HTTPS in production (§12.2). + +--- + +## Endpoints + +The client implements all 4 endpoints defined by the standard: + +| Method | Endpoint | Standard Ref | +|--------|----------|-------------| +| `get_package(package_id)` | `GET /packages/{id}` | §8.2.1 | +| `resolve_package(type, ns, name, version)` | `GET /{type}/{ns}/{name}?version={v}` | §8.2.2 | +| `browse(type, namespace)` | `GET /browse` | §8.4.1 | +| `discover()` | `GET /.well-known/cleverthis-packages` | §8.4.2 | + +All methods are `async` and return dictionaries. They map HTTP errors to typed +exceptions from the [exception hierarchy](exceptions.md). + +--- + +### get_package + +```python +async def get_package(self, package_id: str) -> dict[str, Any] +``` + +Retrieve raw package content by its globally unique Package ID. + +```python +content = await client.get_package( + "pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef" +) +print(content["name"]) # e.g. "order-validator" +``` + +Raises: `PackageNotFoundError`, `InvalidPackageIdError`, `RegistryNetworkError`. + +--- + +### resolve_package + +```python +async def resolve_package( + self, + package_type: str, + namespace: str, + name: str, + version: Optional[str] = None, +) -> dict[str, Any] +``` + +Resolve a named package reference to its concrete Package ID. Performs version +alias resolution according to §4.2. + +```python +# Resolve with a concrete version +result = await client.resolve_package( + package_type="actor", + namespace="acme", + name="order-validator", + version="v1.0.0", +) +print(result["package_id"]) # "pkg_act_..." + +# Resolve with the `latest` alias (default when version is None) +latest = await client.resolve_package( + package_type="skill", + namespace="acme", + name="code-analysis", +) +print(latest["package_id"]) +``` + +Returns a dict with keys `package_id` (str) and `type` (str). + +Raises: `PackageNotFoundError`, `VersionNotFoundError`, `RegistryNetworkError`. + +--- + +### browse + +```python +async def browse( + self, + package_type: Optional[str] = None, + namespace: Optional[str] = None, +) -> dict[str, Any] +``` + +Browse published packages with optional type and namespace filters. + +```python +# List all actor packages +results = await client.browse(package_type="actor") +for pkg in results["packages"]: + print(f"{pkg['name']} ({pkg['namespace']}) — {pkg['description']}") + +# List all packages in a namespace +results = await client.browse(namespace="acme") +print(f"Found {results['count']} packages in acme namespace") +``` + +Returns a dict with keys `packages` (list of package summaries) and `count` (int). + +Raises: `RegistryNetworkError`. + +--- + +### discover + +```python +async def discover(self) -> dict[str, Any] +``` + +Retrieve registry metadata via the well-known discovery endpoint (§8.4.2). + +```python +meta = await client.discover() +print(meta["name"]) # "CleverThis Package Registry" +print(meta["version"]) # "1.0.0" +print(meta["supported_types"]) # ["actor", "graph", "stream", ...] +print(meta["authentication"]) # ["anonymous", "api_key"] +``` + +Raises: `RegistryNetworkError`. + +--- + +## Authentication + +The client supports two authentication modes defined in §9: + +| Mode | How to use | When | +|------|-----------|------| +| **Anonymous** | Omit `api_key` | Reading public packages | +| **API Key** | Pass `api_key="..."` | Publishing or accessing private packages | + +The API key is sent as a `Bearer` token in the `Authorization` header: + +``` +Authorization: Bearer +``` + +--- + +## Error Mapping + +HTTP errors from the server are automatically converted to typed Python +exceptions. See [exceptions.md](exceptions.md) for the complete mapping. + +| HTTP Status | Exception | +|-------------|-----------| +| 400 | `InvalidPackageIdError` | +| 401 | `AuthenticationRequiredError` | +| 403 | `AccessDeniedError` | +| 404 | `PackageNotFoundError` | +| 409 | `ConflictError` | +| 5xx | `RegistryNetworkError` | + +Server-side structured error responses (§13.1) are parsed and the `type` +field is matched against the error-type-to-exception map, allowing the +server to indicate specific error conditions (e.g. `VersionNotFound`) +even when the HTTP status code is less specific. + +--- + +## Async Context Manager + +`RegistryClient` supports async context management for automatic cleanup: + +```python +async with RegistryClient(base_url="https://registry.example.com") as client: + content = await client.get_package("pkg_act_...") +# client.close() is called automatically on exit +``` + +Alternatively, call `close()` explicitly: + +```python +client = RegistryClient(base_url="https://registry.example.com") +try: + content = await client.get_package("pkg_act_...") +finally: + await client.close() +``` + +--- + +## Real-Life Example: Supplier Agent Registration + +Registering a supplier agent that validates part numbers in purchase orders +by pulling an order-validation template from the package registry: + +```python +import asyncio +from typing import Any +from cleveractors.registry import RegistryClient, RegistryCache, CacheFactory + + +async def register_supplier_agent() -> dict[str, Any]: + client = RegistryClient( + base_url="https://registry.example.com", + api_key="supplier-api-key", + ) + cache = RegistryCache(client, max_size=256, ttl=300.0) + + # 1. Discover registry capabilities + meta = await client.discover() + if "actor" not in meta.get("supported_types", []): + raise RuntimeError("Registry does not support actor packages") + + # 2. Resolve the order-validator template to its concrete ID + resolved = await cache.resolve_package( + package_type="actor", + namespace="acme", + name="order-validator", + version="v1.0.0", + ) + package_id = resolved["package_id"] + + # 3. Fetch the full template content + content = await cache.get_package(package_id) + print(f"Loaded template: {content.get('name', package_id)}") + print(f"Stats — hits: {cache.stats.hits}, misses: {cache.stats.misses}") + + await cache.close() + return content + + +if __name__ == "__main__": + result = asyncio.run(register_supplier_agent()) + print(f"Agent config: {list(result.keys())}") +``` \ No newline at end of file diff --git a/docs/registry/exceptions.md b/docs/registry/exceptions.md new file mode 100644 index 0000000..39f4307 --- /dev/null +++ b/docs/registry/exceptions.md @@ -0,0 +1,286 @@ +# Exceptions + +Typed exception hierarchy for the Package Registry Client, mapping server-side +error types from [Package Registry Standard v1.0.0](../actor-registry-standard.md) §13.2 +to Python exception classes. + +```python +from cleveractors.registry.exceptions import RegistryError +``` + +--- + +## Class Hierarchy + +``` +CleverAgentsException + └── RegistryError # Base — all registry errors + ├── PackageNotFoundError # 404 — package does not exist + ├── InvalidPackageIdError # 400 / local — bad Package ID format + ├── InvalidPackageReferenceError # 400 / local — bad reference string + │ └── CircularLocalReferenceError # §11.3.2 — always fatal + ├── VersionNotFoundError # 404 — version/alias cannot be resolved + ├── ValidationError # 400 — package failed validation + ├── AuthenticationRequiredError # 401 — auth needed + ├── AccessDeniedError # 403 — insufficient permissions + ├── ConflictError # 409 — duplicate or conflicting data + └── RegistryNetworkError # 5xx + transport — connection/timeout/5xx +``` + +--- + +## RegistryError (base) + +```python +class RegistryError(CleverAgentsException): + message: str # Human-readable description + details: dict[str, Any] | None # Optional supplementary info from server + original_reference: str | None # The string that triggered the error +``` + +All subclasses inherit `message`, `details`, and `original_reference`. + +--- + +## Exception Reference + +### PackageNotFoundError + +The requested package does not exist in the registry (HTTP 404, type `"PackageNotFound"`). + +```python +from cleveractors.registry.exceptions import PackageNotFoundError + +try: + content = await client.get_package("pkg_act_nonexistent") +except PackageNotFoundError as exc: + print(f"Package not found: {exc.message}") +``` + +### InvalidPackageIdError + +The provided Package ID format is invalid (HTTP 400, type `"InvalidPackageId"`). + +Raised locally by `PackageId.from_string()` and `PackageId.__post_init__()` when the +ID does not match the `pkg__<40-hex-sha1>` format: + +```python +from cleveractors.registry.exceptions import InvalidPackageIdError + +try: + pid = PackageId.from_string("bad-format") +except InvalidPackageIdError as exc: + print(f"Bad ID: {exc.message}") +``` + +### InvalidPackageReferenceError + +The provided package reference string could not be parsed or resolved (HTTP 400, +type `"InvalidPackageReference"`). + +Raised by `ReferenceResolver.parse()` and `ReferenceResolver.resolve()` when +the reference string is malformed, the referenced resource cannot be found, or +the required backend (client or local store) is not configured. + +### CircularLocalReferenceError + +A circular `local:` reference chain was detected (§11.3.2). + +**Always** raised regardless of lenient-mode settings, because cycles represent a +correctness violation, not a graceful-degradation scenario. This is a subclass of +`InvalidPackageReferenceError` — catch the parent class to handle both cycles +and other reference failures uniformly. + +### VersionNotFoundError + +The requested version or version alias does not exist (HTTP 404, type `"VersionNotFound"`). + +```python +from cleveractors.registry.exceptions import VersionNotFoundError + +try: + result = await client.resolve_package( + package_type="actor", + namespace="acme", + name="my-agent", + version="v99.0.0", # does not exist + ) +except VersionNotFoundError as exc: + print(f"Version not found: {exc.message}") +``` + +### ValidationError + +The package failed server-side validation (HTTP 400, type `"ValidationError"`). + +### AuthenticationRequiredError + +Authentication is required for this operation (HTTP 401, type `"AuthenticationRequired"`). + +### AccessDeniedError + +Access to the requested resource is denied (HTTP 403, type `"AccessDenied"`). +Authenticated, but the credential lacks the required permission. + +### ConflictError + +The request conflicts with existing data (HTTP 409, type `"Conflict"`). +Typically raised when attempting to publish a duplicate Package ID. + +### RegistryNetworkError + +Connection errors, timeouts, DNS failures, or HTTP 5xx server errors. + +```python +class RegistryNetworkError(RegistryError): + status_code: int | None # HTTP status for server errors + url: str | None # The URL being accessed +``` + +```python +from cleveractors.registry.exceptions import RegistryNetworkError + +try: + content = await client.get_package("pkg_act_...") +except RegistryNetworkError as exc: + print(f"Network error: {exc.message}") + print(f"Status: {exc.status_code}, URL: {exc.url}") +``` + +--- + +## HTTP Status Mapping + +The `exception_for_status` function maps HTTP status codes to exception classes: + +```python +from cleveractors.registry.exceptions import exception_for_status + +exc = exception_for_status(404, "Package not found") +print(type(exc).__name__) # "PackageNotFoundError" +``` + +| Status | Exception | +|--------|-----------| +| 400 | `InvalidPackageIdError` | +| 401 | `AuthenticationRequiredError` | +| 403 | `AccessDeniedError` | +| 404 | `PackageNotFoundError` | +| 409 | `ConflictError` | +| 500–599 | `RegistryNetworkError` | +| *other* | `RegistryError` | + +--- + +## Server Error Type to Exception Map + +The internal `_ERROR_TYPE_MAP` also maps server-side error type strings +(from structured error responses §13.1) to exception classes for automatic +conversion: + +```python +_ERROR_TYPE_MAP = { + "PackageNotFound": PackageNotFoundError, + "InvalidPackageId": InvalidPackageIdError, + "InvalidPackageReference": InvalidPackageReferenceError, + "VersionNotFound": VersionNotFoundError, + "ValidationError": ValidationError, + "AuthenticationRequired": AuthenticationRequiredError, + "AccessDenied": AccessDeniedError, + "Conflict": ConflictError, + "InternalServerError": RegistryNetworkError, +} +``` + +--- + +## Real-Life Example: Email Categorization System + +Catching and handling specific exception types in a system that resolves +categorization templates from a registry: + +```python +import asyncio +from cleveractors.registry import RegistryClient +from cleveractors.registry.exceptions import ( + AccessDeniedError, + AuthenticationRequiredError, + PackageNotFoundError, + RegistryError, + RegistryNetworkError, + VersionNotFoundError, +) + + +async def load_categorization_templates( + client: RegistryClient, +) -> list[dict]: + """ + Load email categorization templates from the registry. + Handles specific error types with appropriate recovery strategies. + """ + templates = [] + template_refs = [ + ("skill", "acme", "email-categorizer", "v1.x"), + ("skill", "acme", "spam-detector", "latest"), + ("skill", "acme", "priority-classifier", "v2.0.0"), + ] + + for pkg_type, ns, name, version in template_refs: + try: + result = await client.resolve_package( + package_type=pkg_type, + namespace=ns, + name=name, + version=version, + ) + content = await client.get_package(result["package_id"]) + templates.append(content) + + except PackageNotFoundError: + print(f"Template {ns}/{name} not registered — skipping") + continue + + except VersionNotFoundError: + print( + f"Version {version} of {ns}/{name} not found — " + f"trying latest..." + ) + try: + result = await client.resolve_package( + package_type=pkg_type, namespace=ns, name=name + ) + content = await client.get_package(result["package_id"]) + templates.append(content) + except RegistryError as fallback_exc: + print(f"Fallback also failed: {fallback_exc}") + + except AuthenticationRequiredError: + print("API key expired or missing — cannot proceed") + break + + except AccessDeniedError: + print(f"Access denied to {ns}/{name} — check permissions") + continue + + except RegistryNetworkError as exc: + print( + f"Network failure (status={exc.status_code}) — " + f"will retry on next cycle" + ) + continue + + return templates + + +if __name__ == "__main__": + async def main() -> None: + async with RegistryClient( + base_url="https://registry.example.com", + api_key="service-api-key", + ) as client: + templates = await load_categorization_templates(client) + print(f"Loaded {len(templates)} templates") + + asyncio.run(main()) +``` \ No newline at end of file diff --git a/docs/registry/index.md b/docs/registry/index.md new file mode 100644 index 0000000..3b8492c --- /dev/null +++ b/docs/registry/index.md @@ -0,0 +1,107 @@ +# Package Registry Client + +**CleverActors Package Registry Subsystem — Client-Side Implementation of the Package Registry Standard v1.0.0** + +--- + +## Overview + +The Package Registry Client implements the [Package Registry Standard v1.0.0](../actor-registry-standard.md) +on the client side. It provides a complete, async-first library for discovering, resolving, +fetching, caching, and validating versioned packages of AI components — actors, graphs, +agents, templates, skills, MCP servers, and LSP services — from any compliant registry +server. + +## Architecture + +``` +src/cleveractors/registry/ +├── __init__.py Public re-exports +├── types.py Core data types (PackageType, PackageId, PackageReference, PackageContent) +├── exceptions.py Typed exception hierarchy (RegistryError + 9 subclasses) +├── client.py Async HTTP client (RegistryClient) — all 4 API endpoints +├── canonical.py Canonicalizer — RFC-8785 canonical JSON + SHA-1 hashing +├── resolver.py ReferenceResolver — parse and resolve all 3 reference schemes +├── reference_resolver.py PackageContentResolver — multi-server client pool with caching +├── cache.py RegistryCache — LRU + TTL + tamper detection with singleflight +└── local_store.py LocalPackageStore — resolve `local:` references from disk +``` + +### Module Relationships + +``` +PackageReference ──► ReferenceResolver ──► PackageId + │ │ + │ ▼ + │ RegistryClient (HTTP) + │ │ + │ ▼ + └──────────► PackageContentResolver + │ + ┌──────────┼──────────┐ + ▼ ▼ ▼ + RegistryCache Canonicalizer LocalPackageStore +``` + +## Module Map + +| Module | Role | +|--------|------| +| [Types](types.md) | `PackageType`, `PackageId`, `PackageReference`, `PackageContent` — frozen dataclasses and enums | +| [Client](client.md) | `RegistryClient` — async HTTP client implementing all 4 endpoints (§8) | +| [Canonicalizer](canonical.md) | `Canonicalizer` — deterministic RFC-8785 JSON, NFC normalization, SHA-1 hashing | +| [Resolver](resolver.md) | `ReferenceResolver` — parse all 3 reference schemes, resolve version aliases | +| [Exceptions](exceptions.md) | `RegistryError` hierarchy — 9 typed exceptions with HTTP status mapping | +| [Cache](cache.md) | `RegistryCache` — LRU eviction, TTL expiration, SHA-1 tamper detection, singleflight | +| [Integration](integration.md) | End-to-end workflows combining multiple modules | + +## Quickstart + +```python +import asyncio +from cleveractors.registry import ( + RegistryClient, + RegistryCache, + CacheFactory, + ReferenceResolver, + PackageReference, +) + + +async def main() -> None: + # 1. Create an authenticated client for your registry server + client = RegistryClient( + base_url="https://registry.example.com", + api_key="your-api-key", + ) + + # 2. Wrap it with transparent caching (optional but recommended) + cache = RegistryCache(client, max_size=512, ttl=600.0) + + # 3. Fetch a package by its content-addressed ID + content = await cache.get_package( + "pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef" + ) + print(f"Package name: {content['name']}") + + # 4. Resolve a named package with version + resolved = await cache.resolve_package( + package_type="actor", + namespace="acme", + name="order-validator", + version="v1.0.0", + ) + print(f"Resolved to: {resolved['package_id']}") + + # 5. Parse and resolve references with the resolver + resolver = ReferenceResolver(client=client) + ref = resolver.parse("registry.example.com:acme/order-validator@latest") + package_id = await resolver.resolve(ref.original_reference) + print(f"Resolved ID: {package_id}") + + await cache.close() + + +if __name__ == "__main__": + asyncio.run(main()) +``` \ No newline at end of file diff --git a/docs/registry/integration.md b/docs/registry/integration.md new file mode 100644 index 0000000..fc09042 --- /dev/null +++ b/docs/registry/integration.md @@ -0,0 +1,466 @@ +# Integration Examples + +End-to-end workflows that combine multiple modules from the Package Registry +subsystem. Each example is a complete, copy-paste-runnable script (aside from +the registry server URL, which must be configured for your environment). + +--- + +## 1. Electronic Component Ordering Pipeline + +Registers a supplier agent that validates part numbers in purchase orders +by resolving an order-validator template from the package registry at startup. + +This workflow demonstrates the full pipeline: **parse reference → resolve +(version alias) → fetch → cache → instantiate agent**. + +```python +""" +Electronic component ordering pipeline. + +Resolves an order-validator template from the registry, fetches its +full content, and produces a supplier agent configuration. Illustrates +the complete client → cache → resolver pipeline. +""" + +import asyncio +from typing import Any + +from cleveractors.registry import ( + CacheFactory, + PackageContentResolver, + RegistryCache, + RegistryClient, + ReferenceResolver, +) +from cleveractors.registry.exceptions import ( + InvalidPackageReferenceError, + PackageNotFoundError, + RegistryError, + RegistryNetworkError, +) +from cleveractors.registry.types import PackageReference, ReferenceType + + +async def order_pipeline() -> dict[str, Any]: + client = RegistryClient( + base_url="https://registry.example.com", + api_key="supplier-api-key", + ) + + # Transparent content-level cache per §10.3 + cache_factory = CacheFactory(max_size=512, ttl=300.0) + cache = cache_factory.create(client) + + # Resolver for parsing and resolving references + resolver = ReferenceResolver(client=client) + + # 1. Parse the reference string + ref_str = "registry.example.com:acme/order-validator@v1.x" + ref: PackageReference = resolver.parse(ref_str) + + if ref.reference_type != ReferenceType.REGISTRY: + raise RuntimeError(f"Expected registry reference, got {ref.reference_type}") + + print(f"[1] Parsed reference: {ref.namespace}/{ref.name}@{ref.version}") + + # 2. Resolve the version alias to a concrete Package ID + resolved = await cache.resolve_package( + package_type="actor", + namespace=ref.namespace, + name=ref.name, + version=ref.version, + ) + package_id = resolved["package_id"] + print(f"[2] Resolved {ref.version} → {package_id}") + + # 3. Fetch full package content (cache hit if already fetched) + content = await cache.get_package(package_id) + print(f"[3] Fetched content: {content.get('name', package_id)}") + + # 4. Build supplier agent config from the template + agent_config = { + "type": "supplier", + "template_package_id": package_id, + "validators": content.get("validators", []), + "cache_stats": { + "hits": cache.stats.hits, + "misses": cache.stats.misses, + "evictions": cache.stats.evictions, + }, + } + print(f"[4] Agent configured with {len(agent_config['validators'])} validators") + print(f" Cache: hits={cache.stats.hits} misses={cache.stats.misses}") + + await cache.close() + return agent_config + + +if __name__ == "__main__": + config = asyncio.run(order_pipeline()) + print(f"\nDone. Supplier agent config: {list(config.keys())}") +``` + +--- + +## 2. Email Categorization System + +Resolves categorization templates from a registry, fetches tool packages for +domain-specific agents, caches for repeated use, and gracefully handles +missing or inaccessible packages. + +```python +""" +Email categorization system. + +Resolves multiple skill packages from the registry, loads tool packages +for domain-specific agents, and builds a categorisation pipeline. Uses +the content resolver for multi-server client pooling and transparent +two-tier caching. +""" + +import asyncio +from typing import Any + +from cleveractors.registry import ( + CacheFactory, + PackageContentResolver, + RegistryClient, +) +from cleveractors.registry.exceptions import ( + AccessDeniedError, + AuthenticationRequiredError, + PackageNotFoundError, + RegistryNetworkError, + VersionNotFoundError, +) +from cleveractors.registry.types import PackageReference + + +CATEGORY_TEMPLATES = [ + ("skill", "acme", "email-categorizer", "v1.x"), + ("skill", "acme", "spam-detector", "latest"), + ("skill", "acme", "priority-classifier", "v2.0.0"), + ("actor", "acme", "response-generator", "v1.0.0"), +] + +TOOL_PACKAGES = [ + "pkg_skl_aaa1111111111111111111111111111111111", + "pkg_skl_bbb2222222222222222222222222222222222", +] + + +async def build_categorization_system() -> dict[str, Any]: + client = RegistryClient( + base_url="https://registry.example.com", + api_key="categorizer-key", + ) + + cache_factory = CacheFactory(max_size=256, ttl=600.0) + resolver = PackageContentResolver( + api_key="categorizer-key", + cache_factory=cache_factory, + ) + + # Manually pre-warm the resolver's client pool + resolver.clients["https://registry.example.com"] = client + + # Phase 1: Resolve template packages + template_contents: dict[str, dict] = {} + failed_templates: list[str] = [] + + for pkg_type, ns, name, version in CATEGORY_TEMPLATES: + ref = PackageReference.from_string( + f"registry.example.com:{ns}/{name}@{version}" + ) + try: + content = await resolver.aresolve(ref, package_type=pkg_type) + if content is not None: + template_contents[f"{ns}/{name}"] = content + print( + f" ✓ {ns}/{name}@{version} → " + f"{content.get('_package_id', 'unknown')}" + ) + else: + failed_templates.append(f"{ns}/{name}") + except (PackageNotFoundError, VersionNotFoundError) as exc: + print(f" ✗ {ns}/{name}@{version}: {exc}") + failed_templates.append(f"{ns}/{name}") + except AccessDeniedError: + print(f" ✗ {ns}/{name}@{version}: access denied") + failed_templates.append(f"{ns}/{name}") + + # Phase 2: Load tool packages from cache (second fetch hits cache) + print("\nLoading tool packages...") + tool_contents: dict[str, dict] = {} + for pkg_id in TOOL_PACKAGES: + try: + content = await resolver.aresolve( + PackageReference.from_string(f"ID:{pkg_id}"), + package_type="skill", + ) + if content is not None: + tool_contents[pkg_id] = content + print(f" ✓ {pkg_id}") + except Exception as exc: + print(f" ✗ {pkg_id}: {exc}") + + # Phase 3: Aggregate cache statistics + stats = resolver.total_stats + print( + f"\nCache summary: hits={stats.hits} misses={stats.misses}" + f" evictions={stats.evictions}" + ) + + await resolver.close_all() + return { + "templates_loaded": len(template_contents), + "tools_loaded": len(tool_contents), + "failed_templates": failed_templates, + "cache_stats": { + "hits": stats.hits, + "misses": stats.misses, + "evictions": stats.evictions, + }, + } + + +if __name__ == "__main__": + result = asyncio.run(build_categorization_system()) + print(f"\nDone: {result}") +``` + +--- + +## 3. CI/CD Pipeline Actor Verification + +Canonicalizes an actor configuration, compares hashes across environments, +and detects configuration drift. Demonstrates the canonicalization pipeline +and content validation workflow. + +```python +""" +CI/CD pipeline actor verification. + +Compares actor configurations across staging and production environments +using content-addressed SHA-1 hashes. Detects drift, verifies integrity +of cached templates, and reports mismatches with full Package IDs. +""" + +import asyncio +import hashlib +from typing import Any + +from cleveractors.registry import ( + Canonicalizer, + PackageType, + RegistryCache, + RegistryClient, +) +from cleveractors.registry.types import PackageId + + +def canonical_hash(content: dict[str, Any]) -> str: + """Compute the canonical SHA-1 hash of a content dict.""" + canon = Canonicalizer() + canonical = canon.canonicalize(content) + return hashlib.sha1( + canonical.encode("utf-8"), usedforsecurity=False + ).hexdigest() + + +async def verify_deployment( + staging_url: str, + production_url: str, + package_id_str: str, +) -> dict[str, Any]: + """ + Verify that a deployed actor config is identical across environments. + + Args: + staging_url: URL of the staging registry. + production_url: URL of the production registry. + package_id_str: The Package ID to verify. + """ + pid = PackageId.from_string(package_id_str) + result: dict[str, Any] = {"package_id": pid.id_string} + + # Fetch from both environments + async with ( + RegistryClient(base_url=staging_url) as staging_client, + RegistryClient(base_url=production_url) as prod_client, + ): + staging_cache = RegistryCache(staging_client, max_size=1, ttl=60.0) + prod_cache = RegistryCache(prod_client, max_size=1, ttl=60.0) + + staging_content = await staging_cache.get_package(package_id_str) + prod_content = await prod_cache.get_package(package_id_str) + + result["staging_content_name"] = staging_content.get("name") + result["prod_content_name"] = prod_content.get("name") + + # Compute canonical hashes + staging_hash = canonical_hash(staging_content) + prod_hash = canonical_hash(prod_content) + result["staging_hash"] = staging_hash + result["prod_hash"] = prod_hash + + # Verify canonical hash matches the ID + canon = Canonicalizer() + computed_id = canon.compute_package_id(staging_content, pid.package_type) + result["hash_matches_id"] = computed_id.sha1_hex == pid.sha1_hex + + # Compare environments + if staging_hash != prod_hash: + result["drift_detected"] = True + result["staging_canonical_id"] = computed_id.id_string + + computed_prod = canon.compute_package_id( + prod_content, pid.package_type + ) + result["prod_canonical_id"] = computed_prod.id_string + + print( + f"DRIFT DETECTED for {package_id_str}\n" + f" Staging hash: {staging_hash}\n" + f" Production hash: {prod_hash}" + ) + else: + result["drift_detected"] = False + print(f"No drift — {pid.id_string} matches across environments") + + return result + + +async def main() -> None: + PKG_ID = "pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef" + + result = await verify_deployment( + staging_url="https://staging-registry.example.com", + production_url="https://registry.example.com", + package_id_str=PKG_ID, + ) + + print(f"\nVerification result:") + for key, value in result.items(): + print(f" {key}: {value}") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +--- + +## 4. Multi-Tenant SaaS Provisioning + +Resolves agent templates per tenant namespace, validates content integrity +via SHA-1, detects tampered cache entries, and provisions tenant-specific +agent configurations. + +```python +""" +Multi-tenant SaaS provisioning. + +Provisions agent configurations for multiple tenants by resolving +tenant-specific templates from the registry. Validates content integrity +and detects tampered or stale cache entries. +""" + +import asyncio +from typing import Any + +from cleveractors.registry import ( + CacheFactory, + Canonicalizer, + PackageContentResolver, + RegistryClient, + PackageType, +) +from cleveractors.registry.exceptions import ( + RegistryError, + RegistryNetworkError, +) +from cleveractors.registry.types import PackageReference + + +TENANTS = { + "tenant-alpha": "registry.example.com:alpha/workflow-agent@v2.x", + "tenant-beta": "registry.example.com:beta/workflow-agent@v1.x", + "tenant-gamma": "registry.example.com:gamma/workflow-agent@latest", +} + + +async def provision_tenants() -> dict[str, dict[str, Any]]: + client = RegistryClient( + base_url="https://registry.example.com", + api_key="provisioning-key", + ) + cache_factory = CacheFactory(max_size=512, ttl=300.0) + resolver = PackageContentResolver( + api_key="provisioning-key", + cache_factory=cache_factory, + ) + resolver.clients["https://registry.example.com"] = client + canon = Canonicalizer() + + results: dict[str, dict[str, Any]] = {} + + for tenant_id, ref_str in TENANTS.items(): + print(f"\nProvisioning {tenant_id}...") + ref = PackageReference.from_string(ref_str) + + try: + # Resolve via the two-tier cache + content = await resolver.aresolve(ref, package_type="actor") + if content is None: + print(f" ✗ Failed to resolve {ref_str}") + results[tenant_id] = {"status": "failed", "reason": "not_found"} + continue + + package_id_str = content.get("_package_id", "unknown") + + # Validate content integrity + canon.compute_package_id( + content, package_type=PackageType.ACTOR + ) + + results[tenant_id] = { + "status": "provisioned", + "package_id": package_id_str, + "namespace": ref.namespace, + "template_name": content.get("name", "unknown"), + } + print(f" ✓ Resolved to {package_id_str}") + + except RegistryNetworkError as exc: + print(f" ✗ Network error: {exc}") + results[tenant_id] = { + "status": "failed", + "reason": f"network: {exc.status_code}", + } + except RegistryError as exc: + print(f" ✗ Registry error: {exc}") + results[tenant_id] = { + "status": "failed", + "reason": type(exc).__name__, + } + + # Report aggregated cache statistics + stats = resolver.total_stats + print( + f"\nAggregated cache: hits={stats.hits} " + f"misses={stats.misses} evictions={stats.evictions}" + ) + + await resolver.close_all() + return results + + +if __name__ == "__main__": + results = asyncio.run(provision_tenants()) + provisioned = sum( + 1 for r in results.values() if r["status"] == "provisioned" + ) + print(f"\nProvisioned {provisioned}/{len(results)} tenants") +``` \ No newline at end of file diff --git a/docs/registry/resolver.md b/docs/registry/resolver.md new file mode 100644 index 0000000..1c3a40c --- /dev/null +++ b/docs/registry/resolver.md @@ -0,0 +1,238 @@ +# ReferenceResolver + +Parses and resolves package references in all 3 supported schemes, implementing +[Package Registry Standard v1.0.0](../actor-registry-standard.md) §5.3 and §4.2. + +```python +from cleveractors.registry import ReferenceResolver +``` + +--- + +## Constructor + +```python +ReferenceResolver( + client: RegistryClient | None = None, + local_store: LocalPackageStore | None = None, +) +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `client` | `RegistryClient \| None` | `None` | Client for resolving `registry:` references | +| `local_store` | `LocalPackageStore \| None` | `None` | Store for resolving `local:` references | + +Both are optional. Resolution fails with `InvalidPackageReferenceError` if you +attempt to resolve a reference type without the corresponding backend configured. + +--- + +## Reference Schemes + +Three reference schemes are supported per §5.3: + +| Scheme | Format | Backend Required | +|--------|--------|-----------------| +| **Registry** | `server:namespace/name@version` | `RegistryClient` | +| **ID** | `ID:pkg__<40-hex-sha1>` | None (inline parsing) | +| **Local** | `local:` | `LocalPackageStore` | + +--- + +## Static Methods + +### parse + +```python +@staticmethod +def parse(ref_str: str) -> PackageReference +``` + +Parse a reference string into a `PackageReference`. Delegates to +`PackageReference.from_string` and wraps `ValueError` into +`InvalidPackageReferenceError`. + +```python +ref = ReferenceResolver.parse("registry.example.com:acme/agent@v1.0.0") +print(ref.reference_type) # ReferenceType.REGISTRY +print(ref.namespace) # "acme" +``` + +### resolve_version + +```python +def resolve_version( + version: str, + available_versions: list[str], +) -> str +``` + +Resolve a version string (concrete or alias) against available versions per §4.2. + +Static helper; does not require a `ReferenceResolver` instance. + +| Version Format | Behavior | +|----------------|----------| +| `vX.Y.Z` | Concrete: matched directly | +| `latest`, `vx`, `x` | Global alias: resolves to newest concrete version | +| `vX.x` | Major alias: resolves to newest with major X | +| `vX.Y.x` | Minor alias: resolves to newest Z in X.Y series | + +```python +from cleveractors.registry import resolve_version + +available = ["v1.0.0", "v1.0.1", "v1.1.0", "v2.0.0"] + +print(resolve_version("v1.0.0", available)) # "v1.0.0" +print(resolve_version("latest", available)) # "v2.0.0" +print(resolve_version("v1.x", available)) # "v1.1.0" +print(resolve_version("v1.0.x", available)) # "v1.0.1" +``` + +### is_concrete_version / is_version_alias + +```python +def is_concrete_version(version: str) -> bool +def is_version_alias(version: str) -> bool +``` + +```python +print(is_concrete_version("v1.2.3")) # True +print(is_concrete_version("latest")) # False +print(is_version_alias("latest")) # True +print(is_version_alias("v3.x")) # True +print(is_version_alias("v1.2.3")) # False +print(is_version_alias("unknown")) # False +``` + +--- + +## Async Methods + +### resolve + +```python +async def resolve( + self, + ref_str: str, + package_type: str = "actor", +) -> PackageId +``` + +Parse and resolve a reference string to a concrete `PackageId`. + +Resolution strategy by reference type: + +| Type | Strategy | +|------|----------| +| **ID** | Parse the Package ID string directly — no network call | +| **Local** | Resolve via `LocalPackageStore` from the filesystem | +| **Registry** | Query the configured `RegistryClient` to resolve by name/namespace/version | + +```python +from cleveractors.registry import RegistryClient, ReferenceResolver + +client = RegistryClient(base_url="https://registry.example.com") +resolver = ReferenceResolver(client=client) + +# ID reference — resolved inline, no network call +pid = await resolver.resolve( + "ID:pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef" +) +print(pid.sha1_hex) + +# Registry reference — queries the server +pid = await resolver.resolve( + "registry.example.com:acme/web-search@v1.0.0", + package_type="skill", +) +print(pid.id_string) + +await resolver.close() +``` + +--- + +## Async Context Manager + +```python +async with ReferenceResolver(client=client) as resolver: + pid = await resolver.resolve("ID:pkg_act_...") +# resolver.close() called automatically +``` + +--- + +## Real-Life Example: Multi-Tenant SaaS Provisioning + +Resolving agent templates per tenant namespace, detecting and rejecting +malformed references: + +```python +import asyncio +from cleveractors.registry import ( + RegistryClient, + ReferenceResolver, +) +from cleveractors.registry.exceptions import ( + InvalidPackageReferenceError, + PackageNotFoundError, +) +from cleveractors.registry.types import PackageReference, ReferenceType + + +TENANT_AGENTS = { + "tenant-alpha": "registry.example.com:alpha/categorization-agent@v2.x", + "tenant-beta": "registry.example.com:beta/categorization-agent@v1.x", +} + + +async def provision_tenant(tenant_id: str) -> None: + ref_str = TENANT_AGENTS.get(tenant_id) + if ref_str is None: + raise ValueError(f"Unknown tenant: {tenant_id}") + + async with RegistryClient( + base_url="https://registry.example.com" + ) as client: + resolver = ReferenceResolver(client=client) + + # Step 1: Parse — validates reference format + try: + ref = resolver.parse(ref_str) + except InvalidPackageReferenceError as exc: + print(f"Invalid reference for {tenant_id}: {exc}") + return + + # Step 2: Verify it is a registry reference (not ID, not local) + if ref.reference_type != ReferenceType.REGISTRY: + print( + f"Expected registry reference for {tenant_id}, " + f"got {ref.reference_type.value}" + ) + return + + # Step 3: Resolve — queries registry and handles version alias + try: + package_id = await resolver.resolve( + ref_str, package_type="actor" + ) + except PackageNotFoundError as exc: + print(f"Package not found for {tenant_id}: {exc}") + return + except InvalidPackageReferenceError as exc: + print( + f"Resolution failed for {tenant_id}: {exc}" + ) + return + + print( + f"[{tenant_id}] Resolved {ref.namespace}/{ref.name}" + f"@{ref.version} → {package_id.id_string}" + ) + + +if __name__ == "__main__": + asyncio.run(provision_tenant("tenant-alpha")) +``` \ No newline at end of file diff --git a/docs/registry/types.md b/docs/registry/types.md new file mode 100644 index 0000000..a883bf5 --- /dev/null +++ b/docs/registry/types.md @@ -0,0 +1,222 @@ +# Types + +Core immutable data types for the Package Registry Client. All types derive +from the [Package Registry Standard v1.0.0](../actor-registry-standard.md) +and are implemented as frozen dataclasses and enums with strict validation +at construction time. + +--- + +## PackageType + +```python +from cleveractors.registry import PackageType +``` + +An enum of all 8 package types defined in the standard §3.2. Each member +carries a string prefix used in Package ID construction. + +| Member | Prefix | Description | +|--------|--------|-------------| +| `PackageType.ACTOR` | `"act"` | Actor configuration documents | +| `PackageType.GRAPH` | `"grh"` | Graph route definitions | +| `PackageType.STREAM` | `"str"` | Stream route definitions | +| `PackageType.AGENT` | `"agt"` | Agent definitions | +| `PackageType.TEMPLATE` | `"tpl"` | Reusable component templates | +| `PackageType.SKILL` | `"skl"` | Skill packages | +| `PackageType.MCP` | `"mcp"` | MCP server definitions | +| `PackageType.LSP` | `"lsp"` | LSP service definitions | + +Usage: + +```python +pkg_type = PackageType.ACTOR +print(pkg_type.value) # "act" +``` + +--- + +## ReferenceType + +```python +from cleveractors.registry import ReferenceType +``` + +An enum of the three reference schemes defined in the standard §5.3. + +| Member | Value | Reference Format | +|--------|-------|-----------------| +| `ReferenceType.REGISTRY` | `"registry"` | `server:namespace/name@version` | +| `ReferenceType.ID` | `"id"` | `ID:pkg__<40-hex-sha1>` | +| `ReferenceType.LOCAL` | `"local"` | `local:` | + +--- + +## PackageId + +```python +from cleveractors.registry import PackageId +``` + +A frozen, content-addressed package identifier (§5.1). Formatted as: + +``` +pkg__<40-hex-sha1> +``` + +The SHA-1 digest is computed from the canonical form of the package content (§6). + +| Attribute | Type | Description | +|-----------|------|-------------| +| `package_type` | `PackageType` | The package's type | +| `sha1_hex` | `str` | The 40-character lowercase hex SHA-1 digest | + +| Method / Property | Returns | Description | +|-------------------|---------|-------------| +| `id_string` | `str` | The full `pkg__` string | +| `from_string(raw_id)` | `PackageId` | Parse and validate a Package ID string | +| `__str__()` | `str` | Same as `id_string` | + +`PackageId` is immutable — once constructed, neither `package_type` nor +`sha1_hex` can change. Validation runs at construction time: + +- SHA-1 must be exactly 40 lowercase hex characters. +- The type prefix must match a known `PackageType`. +- Empty strings are rejected. + +### Example + +```python +from cleveractors.registry import PackageId, PackageType +from cleveractors.registry.exceptions import InvalidPackageIdError + +# Construct directly +pid = PackageId( + package_type=PackageType.ACTOR, + sha1_hex="a1b2c3d4e5f67890abcdef1234567890abcdef", +) +print(pid.id_string) +# pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef + +# Parse from a string +pid2 = PackageId.from_string( + "pkg_tpl_b1a2c3d4e5f67890abcdef1234567890fedcba" +) +print(pid2.package_type) # PackageType.TEMPLATE +print(pid2.sha1_hex) # "b1a2c3d4e5f67890abcdef1234567890fedcba" + +# Validation rejects malformed IDs +try: + PackageId.from_string("pkg_act_bad") +except InvalidPackageIdError as exc: + print(f"Rejected: {exc}") +``` + +--- + +## PackageReference + +```python +from cleveractors.registry import PackageReference +``` + +A frozen, parsed representation of a reference string (§5.3). Holds the +original string for debugging alongside typed, parsed fields. + +| Attribute | Type | Description | +|-----------|------|-------------| +| `original_reference` | `str` | The verbatim reference string | +| `reference_type` | `ReferenceType` | Which scheme this reference uses | +| `server` | `str \| None` | Registry server hostname (REGISTRY only) | +| `namespace` | `str \| None` | Package namespace (REGISTRY only) | +| `name` | `str \| None` | Package name (REGISTRY) or local path (LOCAL) | +| `version` | `str \| None` | Version string or alias; defaults to `"latest"` | +| `id_string` | `str \| None` | Raw Package ID (ID references only) | +| `package_type` | `str \| None` | Inferred package type prefix | + +| Method | Returns | Description | +|--------|---------|-------------| +| `from_string(raw_ref)` | `PackageReference` | Parse and validate a reference string | + +Three reference formats are supported: + +| Format | Example | +|--------|---------| +| Registry | `"registry.cleverthis.com:acme/web_search@latest"` | +| ID | `"ID:pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef"` | +| Local | `"local:path/to/package.yaml"` | + +Version defaults to `"latest"` when omitted from registry references. + +### Example + +```python +from cleveractors.registry import PackageReference, ReferenceType + +# Parse a registry reference +ref = PackageReference.from_string( + "registry.cleverthis.com:acme/order-validator@v1.0.0" +) +print(ref.server) # "registry.cleverthis.com" +print(ref.namespace) # "acme" +print(ref.name) # "order-validator" +print(ref.version) # "v1.0.0" + +# Parse an ID reference +id_ref = PackageReference.from_string( + "ID:pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef" +) +print(id_ref.reference_type) # ReferenceType.ID +print(id_ref.id_string) # "pkg_act_..." + +# Parse a local reference +local_ref = PackageReference.from_string("local:agents/validator.yaml") +print(local_ref.reference_type) # ReferenceType.LOCAL +print(local_ref.name) # "agents/validator.yaml" + +# Version defaults to "latest" +bare_ref = PackageReference.from_string( + "registry.example.com:acme/my-agent" +) +print(bare_ref.version) # "latest" +``` + +--- + +## PackageContent + +```python +from cleveractors.registry import PackageContent +``` + +A frozen wrapper for fetched package content alongside its resolved identity. +Returned by `RegistryCache` and `PackageContentResolver`. + +| Attribute | Type | Description | +|-----------|------|-------------| +| `id` | `PackageId` | The content-addressed identity | +| `content` | `dict[str, Any]` | The parsed package content (not used for hashing/equality) | +| `original_reference` | `str \| None` | The reference string supplied by the caller | +| `fetched_at` | `datetime` | UTC timestamp of when the content was retrieved (not used for hashing/equality) | + +`PackageContent` is frozen. The `content` and `fetched_at` fields are excluded +from hash and equality comparisons so that two `PackageContent` objects with +the same `id` and `original_reference` compare as equal regardless of when +they were fetched. + +### Example + +```python +from datetime import datetime, timezone +from cleveractors.registry import PackageContent, PackageId, PackageType + +pid = PackageId(package_type=PackageType.ACTOR, sha1_hex="a" * 40) +pkg = PackageContent( + id=pid, + content={"name": "my-actor", "agents": {}}, + original_reference="registry.example.com:acme/my-actor@v1.0.0", +) +print(pkg.id) # PackageId('pkg_act_aaa...') +print(pkg.content["name"]) # "my-actor" +print(pkg.original_reference) # "registry.example.com:acme/my-actor@v1.0.0" +``` \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 0fc189a..9102598 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -7,6 +7,15 @@ edit_uri: src/branch/master/docs nav: - Home: index.md + - Package Registry: + - Overview: registry/index.md + - Types: registry/types.md + - RegistryClient: registry/client.md + - Canonicalizer: registry/canonical.md + - ReferenceResolver: registry/resolver.md + - Exceptions: registry/exceptions.md + - RegistryCache: registry/cache.md + - Integration Examples: registry/integration.md - Development: - Quality Automation: development/quality-automation.md