Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a0230c-b06c-7641-808a-e191b6d1da49
230 lines
9.1 KiB
Python
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)
|