feat: add hub runtime and extension contract
This commit is contained in:
parent
fce19f193f
commit
7e1ec03f0c
44 changed files with 3875 additions and 84 deletions
21
hub_core/runtime/__init__.py
Normal file
21
hub_core/runtime/__init__.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""Primary hub-core runtime composition surfaces.
|
||||
|
||||
Exports are lazy so CLI commands such as migrations do not construct the ASGI
|
||||
application as an import side effect.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
__all__ = ["InMemoryPortStore", "PortStore", "create_app"]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name == "create_app":
|
||||
from hub_core.runtime.app import create_app
|
||||
|
||||
return create_app
|
||||
if name in {"InMemoryPortStore", "PortStore"}:
|
||||
from hub_core.runtime.store import InMemoryPortStore, PortStore
|
||||
|
||||
return {"InMemoryPortStore": InMemoryPortStore, "PortStore": PortStore}[name]
|
||||
raise AttributeError(name)
|
||||
54
hub_core/runtime/app.py
Normal file
54
hub_core/runtime/app.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI, Response, status
|
||||
|
||||
from hub_core import __version__
|
||||
from hub_core.runtime.config import RuntimeSettings
|
||||
from hub_core.runtime.models import HealthResponse, ReadinessResponse
|
||||
from hub_core.runtime.ports import create_ports_router
|
||||
from hub_core.runtime.store import InMemoryPortStore, PortStore
|
||||
from hub_core.runtime.validation import ContractValidator
|
||||
|
||||
|
||||
def create_app(
|
||||
*,
|
||||
settings: RuntimeSettings | None = None,
|
||||
port_store: PortStore | None = None,
|
||||
) -> FastAPI:
|
||||
resolved_settings = settings or RuntimeSettings.from_env()
|
||||
resolved_store = port_store or _create_store(resolved_settings)
|
||||
|
||||
app = FastAPI(
|
||||
title="Hub Core Runtime",
|
||||
version=__version__,
|
||||
description="HelixForge hub framework and named-port runtime.",
|
||||
)
|
||||
app.state.settings = resolved_settings
|
||||
app.state.port_store = resolved_store
|
||||
app.state.contract_validator = ContractValidator()
|
||||
|
||||
@app.get("/healthz", response_model=HealthResponse, tags=["system"])
|
||||
async def healthz() -> HealthResponse:
|
||||
return HealthResponse(version=__version__)
|
||||
|
||||
@app.get("/readyz", response_model=ReadinessResponse, tags=["system"])
|
||||
async def readyz(response: Response) -> ReadinessResponse:
|
||||
ready = resolved_settings.is_ready(resolved_store.backend_name)
|
||||
if not ready:
|
||||
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
return ReadinessResponse(
|
||||
status="ok" if ready else "degraded",
|
||||
checks=resolved_settings.readiness_checks(resolved_store.backend_name),
|
||||
)
|
||||
|
||||
app.include_router(create_ports_router())
|
||||
return app
|
||||
|
||||
|
||||
def _create_store(settings: RuntimeSettings) -> PortStore:
|
||||
if settings.backend == "memory":
|
||||
return InMemoryPortStore()
|
||||
raise RuntimeError(f"Unsupported HUB_CORE_BACKEND '{settings.backend}'")
|
||||
|
||||
|
||||
app = create_app()
|
||||
100
hub_core/runtime/cli.py
Normal file
100
hub_core/runtime/cli.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from importlib.resources import files
|
||||
from typing import Sequence
|
||||
|
||||
from hub_core.mcp import HubCoreMCPServer
|
||||
from hub_core.runtime.config import RuntimeSettings
|
||||
|
||||
|
||||
def build_parser(settings: RuntimeSettings | None = None) -> argparse.ArgumentParser:
|
||||
resolved = settings or RuntimeSettings.from_env()
|
||||
parser = argparse.ArgumentParser(prog="hub-core", description="Hub Core runtime commands")
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
api = commands.add_parser("api", help="Run the HTTP API and named ports")
|
||||
api.add_argument("--host", default=resolved.api_host)
|
||||
api.add_argument("--port", type=int, default=resolved.api_port)
|
||||
|
||||
mcp = commands.add_parser("mcp", help="Run the Hub Core MCP process")
|
||||
mcp.add_argument("--host", default=resolved.mcp_host)
|
||||
mcp.add_argument("--port", type=int, default=resolved.mcp_port)
|
||||
mcp.add_argument("--transport", default=resolved.mcp_transport)
|
||||
mcp.add_argument("--api-base", default=resolved.api_base)
|
||||
|
||||
migrate = commands.add_parser("migrate", help="Run packaged Alembic migrations")
|
||||
migrate.add_argument("revision", nargs="?", default="head")
|
||||
migrate.add_argument("--database-url", default=resolved.database_url)
|
||||
|
||||
conformance = commands.add_parser(
|
||||
"conformance",
|
||||
help="Run the implemented Tier 2/3 profile against an HTTP runtime",
|
||||
)
|
||||
conformance.add_argument("--base-url", default=resolved.api_base)
|
||||
conformance.add_argument("--timeout", type=float, default=10.0)
|
||||
conformance.add_argument("--json", action="store_true", dest="as_json")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
settings = RuntimeSettings.from_env()
|
||||
args = build_parser(settings).parse_args(argv)
|
||||
|
||||
if args.command == "api":
|
||||
_run_api(args.host, args.port)
|
||||
return 0
|
||||
if args.command == "mcp":
|
||||
_run_mcp(args.host, args.port, args.transport, args.api_base)
|
||||
return 0
|
||||
if args.command == "migrate":
|
||||
if not args.database_url:
|
||||
raise SystemExit("hub-core migrate requires --database-url or HUB_CORE_DATABASE_URL")
|
||||
_run_migrations(args.database_url, args.revision)
|
||||
return 0
|
||||
if args.command == "conformance":
|
||||
return _run_conformance(args.base_url, args.timeout, args.as_json)
|
||||
raise AssertionError(f"Unhandled command {args.command}")
|
||||
|
||||
|
||||
def _run_api(host: str, port: int) -> None:
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run("hub_core.runtime.app:app", host=host, port=port)
|
||||
|
||||
|
||||
def _run_mcp(host: str, port: int, transport: str, api_base: str) -> None:
|
||||
server = HubCoreMCPServer(name="hub-core", api_base=api_base)
|
||||
server.mcp.run(transport=transport, host=host, port=port)
|
||||
|
||||
|
||||
def _run_migrations(database_url: str, revision: str) -> None:
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
migration_root = files("hub_core.migrations")
|
||||
config = Config()
|
||||
config.set_main_option("script_location", str(migration_root))
|
||||
config.set_main_option("sqlalchemy.url", _sync_database_url(database_url))
|
||||
command.upgrade(config, revision)
|
||||
|
||||
|
||||
def _run_conformance(base_url: str, timeout: float, as_json: bool) -> int:
|
||||
import httpx
|
||||
|
||||
from hub_core.conformance import ConformanceHarness
|
||||
|
||||
with httpx.Client(base_url=base_url, timeout=timeout) as target:
|
||||
report = ConformanceHarness(target).run()
|
||||
if as_json:
|
||||
print(json.dumps(report.to_dict(), indent=2, sort_keys=True))
|
||||
else:
|
||||
for check in report.checks:
|
||||
print(f"{check.status.upper():4} Tier {check.tier} {check.check_id}: {check.summary}")
|
||||
print(f"{report.passed_count}/{len(report.checks)} implemented checks passed")
|
||||
return 0 if report.passed else 1
|
||||
|
||||
|
||||
def _sync_database_url(database_url: str) -> str:
|
||||
return database_url.replace("postgresql+asyncpg://", "postgresql+psycopg2://")
|
||||
59
hub_core/runtime/config.py
Normal file
59
hub_core/runtime/config.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RuntimeSettings:
|
||||
environment: str = "development"
|
||||
backend: str = "memory"
|
||||
allow_ephemeral: bool = True
|
||||
api_host: str = "127.0.0.1"
|
||||
api_port: int = 8010
|
||||
api_base: str = "http://127.0.0.1:8010"
|
||||
mcp_host: str = "127.0.0.1"
|
||||
mcp_port: int = 8011
|
||||
mcp_transport: str = "http"
|
||||
database_url: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> RuntimeSettings:
|
||||
environment = os.getenv("HUB_CORE_ENV", "development")
|
||||
default_ephemeral = environment in {"development", "test"}
|
||||
api_host = os.getenv("HUB_CORE_API_HOST", "127.0.0.1")
|
||||
api_port = int(os.getenv("HUB_CORE_API_PORT", "8010"))
|
||||
return cls(
|
||||
environment=environment,
|
||||
backend=os.getenv("HUB_CORE_BACKEND", "memory"),
|
||||
allow_ephemeral=_env_bool("HUB_CORE_ALLOW_EPHEMERAL", default_ephemeral),
|
||||
api_host=api_host,
|
||||
api_port=api_port,
|
||||
api_base=os.getenv("HUB_CORE_API_BASE", f"http://127.0.0.1:{api_port}"),
|
||||
mcp_host=os.getenv("HUB_CORE_MCP_HOST", "127.0.0.1"),
|
||||
mcp_port=int(os.getenv("HUB_CORE_MCP_PORT", "8011")),
|
||||
mcp_transport=os.getenv("HUB_CORE_MCP_TRANSPORT", "http"),
|
||||
database_url=os.getenv("HUB_CORE_DATABASE_URL") or os.getenv("DATABASE_URL"),
|
||||
)
|
||||
|
||||
def readiness_checks(self, store_backend: str) -> dict[str, str]:
|
||||
ephemeral_allowed = store_backend != "memory" or self.allow_ephemeral
|
||||
return {
|
||||
"environment": self.environment,
|
||||
"configured_backend": self.backend,
|
||||
"active_backend": store_backend,
|
||||
"ephemeral_backend": "allowed" if ephemeral_allowed else "not_allowed",
|
||||
"contract": "helixforge.hub-extension/0.1.0",
|
||||
}
|
||||
|
||||
def is_ready(self, store_backend: str) -> bool:
|
||||
return self.backend == store_backend and (
|
||||
store_backend != "memory" or self.allow_ephemeral
|
||||
)
|
||||
72
hub_core/runtime/models.py
Normal file
72
hub_core/runtime/models.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class RuntimeModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class RegistryRegistration(RuntimeModel):
|
||||
descriptor: dict[str, Any]
|
||||
manifest: dict[str, Any]
|
||||
|
||||
|
||||
class MessageCommand(RuntimeModel):
|
||||
schema_version: str
|
||||
correlation_id: UUID
|
||||
conversation_id: UUID | None = None
|
||||
from_address: str = Field(min_length=1)
|
||||
to_addresses: list[str] = Field(min_length=1)
|
||||
body: str = Field(min_length=1)
|
||||
subject_refs: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class EventCommand(RuntimeModel):
|
||||
schema_version: str
|
||||
correlation_id: UUID
|
||||
event_type: str = Field(pattern=r"^[a-z][a-z0-9]*(?:[.-][a-z0-9][a-z0-9-]*)+$")
|
||||
occurred_at: datetime
|
||||
subject_refs: dict[str, str] = Field(default_factory=dict)
|
||||
payload: dict[str, Any]
|
||||
|
||||
|
||||
class Provenance(RuntimeModel):
|
||||
source_system: str
|
||||
source_ref: str
|
||||
schema_version: str
|
||||
content_hash: str | None = None
|
||||
indexed_at: datetime
|
||||
|
||||
|
||||
class PortRecord(RuntimeModel):
|
||||
id: str
|
||||
data: dict[str, Any]
|
||||
provenance: Provenance
|
||||
|
||||
|
||||
class PortCollection(RuntimeModel):
|
||||
items: list[PortRecord]
|
||||
next_cursor: str | None = None
|
||||
|
||||
|
||||
class PortAccepted(RuntimeModel):
|
||||
id: str
|
||||
status: Literal["accepted", "duplicate"]
|
||||
correlation_id: UUID
|
||||
|
||||
|
||||
class HealthResponse(RuntimeModel):
|
||||
service: str = "hub-core"
|
||||
status: Literal["ok"] = "ok"
|
||||
version: str
|
||||
|
||||
|
||||
class ReadinessResponse(RuntimeModel):
|
||||
service: str = "hub-core"
|
||||
status: Literal["ok", "degraded"]
|
||||
checks: dict[str, str]
|
||||
127
hub_core/runtime/ports.py
Normal file
127
hub_core/runtime/ports.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
from jsonschema import ValidationError
|
||||
|
||||
from hub_core.runtime.models import (
|
||||
EventCommand,
|
||||
MessageCommand,
|
||||
PortAccepted,
|
||||
PortCollection,
|
||||
PortRecord,
|
||||
RegistryRegistration,
|
||||
)
|
||||
from hub_core.runtime.store import PortStore
|
||||
from hub_core.runtime.validation import ContractValidator
|
||||
|
||||
|
||||
def get_port_store(request: Request) -> PortStore:
|
||||
return request.app.state.port_store
|
||||
|
||||
|
||||
def get_contract_validator(request: Request) -> ContractValidator:
|
||||
return request.app.state.contract_validator
|
||||
|
||||
|
||||
def create_ports_router() -> APIRouter:
|
||||
router = APIRouter(prefix="/ports")
|
||||
|
||||
@router.post(
|
||||
"/registry/registrations",
|
||||
response_model=PortAccepted,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
tags=["registry"],
|
||||
openapi_extra={"x-port-id": "port.registry", "x-direction": "in"},
|
||||
)
|
||||
async def register_extension(
|
||||
body: RegistryRegistration,
|
||||
x_correlation_id: UUID = Header(alias="X-Correlation-ID"),
|
||||
store: PortStore = Depends(get_port_store),
|
||||
validator: ContractValidator = Depends(get_contract_validator),
|
||||
) -> PortAccepted:
|
||||
try:
|
||||
validator.validate_registration(body)
|
||||
except (ValidationError, ValueError) as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return await store.register_extension(body, x_correlation_id)
|
||||
|
||||
@router.get(
|
||||
"/messaging/messages",
|
||||
response_model=PortCollection,
|
||||
tags=["messaging"],
|
||||
openapi_extra={"x-port-id": "port.messaging", "x-direction": "out"},
|
||||
)
|
||||
async def list_messages(
|
||||
address: str,
|
||||
conversation_id: UUID | None = None,
|
||||
store: PortStore = Depends(get_port_store),
|
||||
) -> PortCollection:
|
||||
return await store.list_messages(address, conversation_id)
|
||||
|
||||
@router.post(
|
||||
"/messaging/messages",
|
||||
response_model=PortAccepted,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
tags=["messaging"],
|
||||
openapi_extra={"x-port-id": "port.messaging", "x-direction": "in"},
|
||||
)
|
||||
async def send_message(
|
||||
body: MessageCommand,
|
||||
store: PortStore = Depends(get_port_store),
|
||||
) -> PortAccepted:
|
||||
return await store.send_message(body)
|
||||
|
||||
@router.post(
|
||||
"/events/progress",
|
||||
response_model=PortAccepted,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
tags=["events"],
|
||||
openapi_extra={"x-port-id": "port.events.progress", "x-direction": "in"},
|
||||
)
|
||||
async def append_progress(
|
||||
body: EventCommand,
|
||||
store: PortStore = Depends(get_port_store),
|
||||
validator: ContractValidator = Depends(get_contract_validator),
|
||||
) -> PortAccepted:
|
||||
try:
|
||||
validator.validate_event_family(body.event_type, "progress")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return await store.append_progress(body)
|
||||
|
||||
@router.post(
|
||||
"/events/interaction",
|
||||
response_model=PortAccepted,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
tags=["events"],
|
||||
openapi_extra={"x-port-id": "port.events.interaction", "x-direction": "in"},
|
||||
)
|
||||
async def append_interaction(
|
||||
body: EventCommand,
|
||||
store: PortStore = Depends(get_port_store),
|
||||
validator: ContractValidator = Depends(get_contract_validator),
|
||||
) -> PortAccepted:
|
||||
try:
|
||||
validator.validate_event_family(body.event_type, "interaction")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return await store.append_interaction(body)
|
||||
|
||||
@router.get(
|
||||
"/projections/{projection_id}",
|
||||
response_model=PortRecord,
|
||||
tags=["projections"],
|
||||
openapi_extra={"x-port-id": "port.projection.query", "x-direction": "out"},
|
||||
)
|
||||
async def query_projection(
|
||||
projection_id: str,
|
||||
store: PortStore = Depends(get_port_store),
|
||||
) -> PortRecord:
|
||||
projection = await store.query_projection(projection_id)
|
||||
if projection is None:
|
||||
raise HTTPException(status_code=404, detail=f"Projection '{projection_id}' not found")
|
||||
return projection
|
||||
|
||||
return router
|
||||
179
hub_core/runtime/store.py
Normal file
179
hub_core/runtime/store.py
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Protocol
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from hub_core.contracts import CONTRACT_VERSION
|
||||
from hub_core.runtime.models import (
|
||||
EventCommand,
|
||||
MessageCommand,
|
||||
PortAccepted,
|
||||
PortCollection,
|
||||
PortRecord,
|
||||
Provenance,
|
||||
RegistryRegistration,
|
||||
)
|
||||
|
||||
|
||||
class PortStore(Protocol):
|
||||
"""Persistence boundary for the initial hub-core runtime ports."""
|
||||
|
||||
backend_name: str
|
||||
|
||||
async def register_extension(
|
||||
self,
|
||||
registration: RegistryRegistration,
|
||||
correlation_id: UUID,
|
||||
) -> PortAccepted: ...
|
||||
|
||||
async def send_message(self, command: MessageCommand) -> PortAccepted: ...
|
||||
|
||||
async def list_messages(self, address: str, conversation_id: UUID | None) -> PortCollection: ...
|
||||
|
||||
async def append_progress(self, command: EventCommand) -> PortAccepted: ...
|
||||
|
||||
async def append_interaction(self, command: EventCommand) -> PortAccepted: ...
|
||||
|
||||
async def query_projection(self, projection_id: str) -> PortRecord | None: ...
|
||||
|
||||
|
||||
class InMemoryPortStore:
|
||||
"""Deterministic ephemeral backend for local runtime and conformance tests.
|
||||
|
||||
Production readiness rejects this backend unless explicitly allowed. The
|
||||
store deliberately keeps progress and interaction event families separate.
|
||||
"""
|
||||
|
||||
backend_name = "memory"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = asyncio.Lock()
|
||||
self._registrations: dict[str, dict[str, Any]] = {}
|
||||
self._messages: list[dict[str, Any]] = []
|
||||
self._progress_events: list[dict[str, Any]] = []
|
||||
self._interaction_events: list[dict[str, Any]] = []
|
||||
|
||||
async def register_extension(
|
||||
self,
|
||||
registration: RegistryRegistration,
|
||||
correlation_id: UUID,
|
||||
) -> PortAccepted:
|
||||
hub_slug = str(registration.descriptor["hub_slug"])
|
||||
value = registration.model_dump(mode="json")
|
||||
async with self._lock:
|
||||
duplicate = self._registrations.get(hub_slug) == value
|
||||
self._registrations[hub_slug] = deepcopy(value)
|
||||
return PortAccepted(
|
||||
id=hub_slug,
|
||||
status="duplicate" if duplicate else "accepted",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
async def send_message(self, command: MessageCommand) -> PortAccepted:
|
||||
message_id = uuid4()
|
||||
value = {
|
||||
"id": str(message_id),
|
||||
"created_at": _now().isoformat(),
|
||||
**command.model_dump(mode="json"),
|
||||
}
|
||||
async with self._lock:
|
||||
self._messages.append(value)
|
||||
return PortAccepted(
|
||||
id=str(message_id),
|
||||
status="accepted",
|
||||
correlation_id=command.correlation_id,
|
||||
)
|
||||
|
||||
async def list_messages(self, address: str, conversation_id: UUID | None) -> PortCollection:
|
||||
async with self._lock:
|
||||
values = [
|
||||
deepcopy(message)
|
||||
for message in self._messages
|
||||
if address in message["to_addresses"]
|
||||
and (
|
||||
conversation_id is None
|
||||
or message.get("conversation_id") == str(conversation_id)
|
||||
)
|
||||
]
|
||||
return PortCollection(items=[self._record("message", value) for value in values])
|
||||
|
||||
async def append_progress(self, command: EventCommand) -> PortAccepted:
|
||||
return await self._append_event(command, self._progress_events, "progress")
|
||||
|
||||
async def append_interaction(self, command: EventCommand) -> PortAccepted:
|
||||
return await self._append_event(command, self._interaction_events, "interaction")
|
||||
|
||||
async def query_projection(self, projection_id: str) -> PortRecord | None:
|
||||
async with self._lock:
|
||||
sources: Mapping[str, Any] = {
|
||||
"hub_registry": list(self._registrations.values()),
|
||||
"messages": self._messages,
|
||||
"progress_events": self._progress_events,
|
||||
"interaction_events": self._interaction_events,
|
||||
}
|
||||
if projection_id not in sources:
|
||||
return None
|
||||
items = deepcopy(sources[projection_id])
|
||||
return self._record(
|
||||
projection_id,
|
||||
{
|
||||
"projection_id": projection_id,
|
||||
"items": items,
|
||||
"rebuild_from": _rebuild_sources(projection_id),
|
||||
},
|
||||
)
|
||||
|
||||
async def _append_event(
|
||||
self,
|
||||
command: EventCommand,
|
||||
target: list[dict[str, Any]],
|
||||
family: str,
|
||||
) -> PortAccepted:
|
||||
event_id = uuid4()
|
||||
value = {
|
||||
"id": str(event_id),
|
||||
"family": family,
|
||||
"recorded_at": _now().isoformat(),
|
||||
**command.model_dump(mode="json"),
|
||||
}
|
||||
async with self._lock:
|
||||
target.append(value)
|
||||
return PortAccepted(
|
||||
id=str(event_id),
|
||||
status="accepted",
|
||||
correlation_id=command.correlation_id,
|
||||
)
|
||||
|
||||
def _record(self, kind: str, value: dict[str, Any]) -> PortRecord:
|
||||
encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
||||
record_id = str(value.get("id") or kind)
|
||||
return PortRecord(
|
||||
id=record_id,
|
||||
data=deepcopy(value),
|
||||
provenance=Provenance(
|
||||
source_system="hub-core-memory",
|
||||
source_ref=f"memory://{kind}/{record_id}",
|
||||
schema_version=CONTRACT_VERSION,
|
||||
content_hash=hashlib.sha256(encoded).hexdigest(),
|
||||
indexed_at=_now(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _rebuild_sources(projection_id: str) -> list[str]:
|
||||
return {
|
||||
"hub_registry": ["hub_descriptors", "hub_manifests"],
|
||||
"messages": ["messages"],
|
||||
"progress_events": ["progress_events"],
|
||||
"interaction_events": ["interaction_events"],
|
||||
}[projection_id]
|
||||
48
hub_core/runtime/validation.py
Normal file
48
hub_core/runtime/validation.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from jsonschema import Draft202012Validator, FormatChecker
|
||||
|
||||
from hub_core.contracts import extension_contract_root
|
||||
from hub_core.runtime.models import RegistryRegistration
|
||||
|
||||
|
||||
class ContractValidator:
|
||||
"""Validate runtime registration input against the packaged contract."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
contract_root = extension_contract_root()
|
||||
schema_root = contract_root.joinpath("schemas")
|
||||
self._descriptor = _validator(schema_root.joinpath("hub-descriptor.schema.json"))
|
||||
self._manifest = _validator(schema_root.joinpath("hub-manifest.schema.json"))
|
||||
catalog = json.loads(
|
||||
contract_root.joinpath("catalogs", "event-types.json").read_text(encoding="utf-8")
|
||||
)
|
||||
self._event_families = {
|
||||
entry["type"]: entry["family"] for entry in catalog["event_types"]
|
||||
}
|
||||
|
||||
def validate_registration(self, registration: RegistryRegistration) -> None:
|
||||
self._descriptor.validate(registration.descriptor)
|
||||
self._manifest.validate(registration.manifest)
|
||||
descriptor_id = registration.descriptor.get("reuse_surface_id")
|
||||
manifest_id = registration.manifest.get("reuse_surface_id")
|
||||
if descriptor_id != manifest_id:
|
||||
raise ValueError("descriptor and manifest reuse_surface_id must match")
|
||||
|
||||
def validate_event_family(self, event_type: str, expected_family: str) -> None:
|
||||
actual_family = self._event_families.get(event_type)
|
||||
if actual_family is None:
|
||||
raise ValueError(f"event type '{event_type}' is not cataloged")
|
||||
if actual_family != expected_family:
|
||||
raise ValueError(
|
||||
f"event type '{event_type}' belongs to '{actual_family}', not '{expected_family}'"
|
||||
)
|
||||
|
||||
|
||||
def _validator(resource: Any) -> Draft202012Validator:
|
||||
schema = json.loads(resource.read_text(encoding="utf-8"))
|
||||
Draft202012Validator.check_schema(schema)
|
||||
return Draft202012Validator(schema, format_checker=FormatChecker())
|
||||
Loading…
Add table
Add a link
Reference in a new issue