hub-core/hub_core/runtime/cli.py
tegwick ed3feed310
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / pytest-smoke (push) Failing after 2s
feat: reconcile legacy message identities
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a0230c-b06c-7641-808a-e191b6d1da49
2026-08-23 11:59:52 +02:00

230 lines
9.1 KiB
Python

from __future__ import annotations
import argparse
import json
import uuid
from importlib.resources import files
from pathlib import Path
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)
migration = commands.add_parser("migration", help="Export, validate, or import migration bundles")
migration_commands = migration.add_subparsers(dest="migration_command", required=True)
migration_validate = migration_commands.add_parser("validate")
migration_validate.add_argument("bundle", type=Path)
migration_validate.add_argument("--output", type=Path)
migration_import = migration_commands.add_parser("import")
migration_import.add_argument("bundle", type=Path)
migration_import.add_argument("--database-url", default=resolved.database_url)
migration_import.add_argument("--dry-run", action="store_true")
migration_import.add_argument("--output", type=Path)
migration_export = migration_commands.add_parser("export")
migration_export.add_argument("--database-url", default=resolved.database_url)
migration_export.add_argument("--source-revision")
migration_export.add_argument("--output", type=Path, required=True)
message_alias = commands.add_parser(
"message-alias",
help="Manage explicit historical message identity aliases",
)
message_alias_commands = message_alias.add_subparsers(
dest="message_alias_command",
required=True,
)
message_alias_register = message_alias_commands.add_parser(
"register",
help="Bind one exact historical reference to an existing canonical message",
)
message_alias_register.add_argument("--alias", required=True)
message_alias_register.add_argument("--message-id", type=uuid.UUID, required=True)
message_alias_register.add_argument("--source", required=True)
message_alias_register.add_argument("--reason", required=True)
message_alias_register.add_argument("--created-by", required=True)
message_alias_register.add_argument("--database-url", default=resolved.database_url)
message_alias_register.add_argument(
"--confirm",
action="store_true",
help="Confirm the attributable database write",
)
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 == "migration":
return _run_migration(args)
if args.command == "message-alias":
return _run_message_alias(args)
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 _run_migration(args: argparse.Namespace) -> int:
import asyncio
from hub_core.runtime.migration import (
export_bundle,
import_bundle,
load_bundle,
validate_bundle,
)
from hub_core.runtime.postgres_store import PostgresPortStore
if args.migration_command == "validate":
report = validate_bundle(load_bundle(args.bundle))
else:
if not args.database_url:
raise SystemExit("migration command requires --database-url or HUB_CORE_DATABASE_URL")
store = PostgresPortStore.from_url(args.database_url)
async def run() -> dict:
try:
if args.migration_command == "import":
return await import_bundle(store, load_bundle(args.bundle), dry_run=args.dry_run)
return await export_bundle(store, source_revision=args.source_revision)
finally:
await store.aclose()
report = asyncio.run(run())
rendered = json.dumps(report, indent=2, sort_keys=True)
if args.output:
args.output.write_text(rendered + "\n")
else:
print(rendered)
return 0 if report.get("ok", True) else 1
def _run_message_alias(args: argparse.Namespace) -> int:
import asyncio
from dataclasses import asdict
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from hub_core.message_identity import MessageIdentityError, register_message_alias
if args.message_alias_command != "register":
raise AssertionError(f"Unhandled message alias command {args.message_alias_command}")
if not args.confirm:
raise SystemExit("message-alias register requires --confirm")
if not args.database_url:
raise SystemExit(
"message-alias register requires --database-url or HUB_CORE_DATABASE_URL"
)
async def run() -> dict:
engine = create_async_engine(_async_database_url(args.database_url))
sessions = async_sessionmaker(engine, expire_on_commit=False)
try:
async with sessions() as session:
result = await register_message_alias(
session,
alias=args.alias,
message_id=args.message_id,
source=args.source,
reason=args.reason,
created_by=args.created_by,
)
report = asdict(result)
report["message_id"] = str(result.message_id)
report["ok"] = True
return report
finally:
await engine.dispose()
try:
report = asyncio.run(run())
except MessageIdentityError as exc:
print(json.dumps({"ok": False, "error": str(exc)}, sort_keys=True))
return 1
print(json.dumps(report, indent=2, sort_keys=True))
return 0
def _sync_database_url(database_url: str) -> str:
return database_url.replace("postgresql+asyncpg://", "postgresql+psycopg2://")
def _async_database_url(database_url: str) -> str:
if database_url.startswith("postgresql://"):
return database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
return database_url.replace("postgresql+psycopg2://", "postgresql+asyncpg://", 1)