feat: add hub runtime and extension contract
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / pytest-smoke (push) Failing after 0s

This commit is contained in:
tegwick 2026-08-21 10:58:03 +02:00
parent fce19f193f
commit 7e1ec03f0c
44 changed files with 3875 additions and 84 deletions

View 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
View 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
View 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://")

View 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
)

View 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
View 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
View 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]

View 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())