Add hub-core package, docs, and State Hub integration scaffold

Extract the first reusable slice (models, schemas, routers, MCP, migrations)
from state-hub with INTENT/SCOPE, agent instructions, workplan, and aligned
inter_hub capability registry index.
This commit is contained in:
2026-06-16 02:39:36 +02:00
parent d3ee203a3a
commit 986ac4d40b
52 changed files with 4085 additions and 3 deletions

5
hub_core/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""Reusable primitives for FOS hub services."""
__all__ = ["__version__"]
__version__ = "0.1.0"

18
hub_core/database.py Normal file
View File

@@ -0,0 +1,18 @@
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
def make_engine(database_url: str, **kwargs: object) -> AsyncEngine:
return create_async_engine(database_url, **kwargs)
def make_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
return async_sessionmaker(engine, expire_on_commit=False)
async def session_from_factory(
factory: async_sessionmaker[AsyncSession],
) -> AsyncGenerator[AsyncSession, None]:
async with factory() as session:
yield session

37
hub_core/events.py Normal file
View File

@@ -0,0 +1,37 @@
"""Canonical event types for reusable FOS hub progress streams."""
RISK_SURFACED = "risk_surfaced"
RISK_MITIGATED = "risk_mitigated"
RISK_ESCALATED = "risk_escalated"
ALERT_RAISED = "alert_raised"
ALERT_ACKNOWLEDGED = "alert_acknowledged"
ALERT_RESOLVED = "alert_resolved"
RISK_EVENT_TYPES = frozenset(
{
RISK_SURFACED,
RISK_MITIGATED,
RISK_ESCALATED,
}
)
ALERT_EVENT_TYPES = frozenset(
{
ALERT_RAISED,
ALERT_ACKNOWLEDGED,
ALERT_RESOLVED,
}
)
FOS10_EVENT_TYPES = RISK_EVENT_TYPES | ALERT_EVENT_TYPES
__all__ = [
"ALERT_ACKNOWLEDGED",
"ALERT_EVENT_TYPES",
"ALERT_RAISED",
"ALERT_RESOLVED",
"FOS10_EVENT_TYPES",
"RISK_ESCALATED",
"RISK_EVENT_TYPES",
"RISK_MITIGATED",
"RISK_SURFACED",
]

3
hub_core/mcp/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from hub_core.mcp.server import HubCoreMCPServer
__all__ = ["HubCoreMCPServer"]

411
hub_core/mcp/server.py Normal file
View File

@@ -0,0 +1,411 @@
from __future__ import annotations
import json
from typing import Any
import httpx
from fastmcp import FastMCP
from hub_core.utils.routing import normalize_trailing_slash
class HubCoreMCPServer:
"""FastMCP base server for generic FOS hub tools.
The MCP layer is intentionally a thin HTTP client. Hubs keep their business
rules in FastAPI routers and inject only the API base URL here.
"""
def __init__(
self,
*,
name: str,
api_base: str,
instructions: str | None = None,
register_tools: bool = True,
) -> None:
self.api_base = api_base.rstrip("/")
self.mcp = FastMCP(
name=name,
instructions=instructions or "Generic FOS hub MCP server.",
)
if register_tools:
self.register_core_tools()
def register_core_tools(self) -> None:
@self.mcp.tool()
def get_state_summary() -> str:
return self._json(self._get("/state/summary/"))
@self.mcp.tool()
def list_domains(status: str | None = None) -> str:
return self._json(self._get("/domains/", {"status": status}))
@self.mcp.tool()
def get_domain_summary(domain_slug: str) -> str:
return self._json(self._get(f"/domains/{domain_slug}/"))
@self.mcp.tool()
def get_domain(domain_slug: str) -> str:
return self._json(self._get(f"/domains/{domain_slug}/"))
@self.mcp.tool()
def send_message(
from_agent: str,
to_agent: str,
subject: str,
body: str,
thread_id: str | None = None,
) -> str:
return self._json(
self._post(
"/messages/",
{
"from_agent": from_agent,
"to_agent": to_agent,
"subject": subject,
"body": body,
"thread_id": thread_id,
},
)
)
@self.mcp.tool()
def get_messages(
to_agent: str | None = None,
from_agent: str | None = None,
unread_only: bool = False,
limit: int = 50,
) -> str:
return self._json(
self._get(
"/messages/",
{
"to_agent": to_agent,
"from_agent": from_agent,
"unread_only": unread_only,
"limit": limit,
},
)
)
@self.mcp.tool()
def mark_message_read(message_id: str) -> str:
return self._json(self._patch(f"/messages/{message_id}/read/", {}))
@self.mcp.tool()
def reply_to_message(message_id: str, from_agent: str, body: str) -> str:
return self._json(
self._post(
f"/messages/{message_id}/reply/",
{"from_agent": from_agent, "body": body},
)
)
@self.mcp.tool()
def register_capability(
domain: str,
capability_type: str,
title: str,
description: str | None = None,
keywords: list[str] | None = None,
repo_slug: str | None = None,
) -> str:
return self._json(
self._post(
"/capability-catalog/",
{
"domain": domain,
"capability_type": capability_type,
"title": title,
"description": description,
"keywords": keywords or [],
"repo_slug": repo_slug,
},
)
)
@self.mcp.tool()
def list_capabilities(
domain: str | None = None,
capability_type: str | None = None,
status: str | None = None,
) -> str:
return self._json(
self._get(
"/capability-catalog/",
{"domain": domain, "capability_type": capability_type, "status": status},
)
)
@self.mcp.tool()
def request_capability(
title: str,
capability_type: str,
requesting_domain: str,
requesting_agent: str,
description: str | None = None,
priority: str = "medium",
request_context: dict[str, Any] | None = None,
catalog_entry_id: str | None = None,
) -> str:
return self._json(
self._post(
"/capability-requests/",
{
"title": title,
"description": description,
"capability_type": capability_type,
"priority": priority,
"requesting_domain": requesting_domain,
"requesting_agent": requesting_agent,
"request_context": request_context,
"catalog_entry_id": catalog_entry_id,
},
)
)
@self.mcp.tool()
def accept_capability_request(
request_id: str,
fulfilling_agent: str,
fulfillment_context: dict[str, Any] | None = None,
) -> str:
return self._json(
self._post(
f"/capability-requests/{request_id}/accept/",
{
"fulfilling_agent": fulfilling_agent,
"fulfillment_context": fulfillment_context,
},
)
)
@self.mcp.tool()
def update_capability_request_status(
request_id: str,
status: str,
note: str | None = None,
) -> str:
return self._json(
self._patch(
f"/capability-requests/{request_id}/status/",
{"status": status, "note": note},
)
)
@self.mcp.tool()
def list_capability_requests(
domain: str | None = None,
status: str | None = None,
capability_type: str | None = None,
) -> str:
return self._json(
self._get(
"/capability-requests/",
{"domain": domain, "status": status, "capability_type": capability_type},
)
)
@self.mcp.tool()
def get_capability_request(request_id: str) -> str:
return self._json(self._get(f"/capability-requests/{request_id}/"))
@self.mcp.tool()
def register_repo(
domain_slug: str,
slug: str,
name: str,
local_path: str | None = None,
remote_url: str | None = None,
git_fingerprint: str | None = None,
description: str | None = None,
) -> str:
return self._json(
self._post(
"/repos/",
{
"domain_slug": domain_slug,
"slug": slug,
"name": name,
"local_path": local_path,
"remote_url": remote_url,
"git_fingerprint": git_fingerprint,
"description": description,
},
)
)
@self.mcp.tool()
def update_repo_path(repo_slug: str, host: str, path: str) -> str:
return self._json(
self._post(f"/repos/{repo_slug}/paths/", {"host": host, "path": path})
)
@self.mcp.tool()
def list_domain_repos(domain_slug: str) -> str:
return self._json(self._get("/repos/", {"domain": domain_slug}))
@self.mcp.tool()
def check_repo_doi(repo_slug: str, force_refresh: bool = False) -> str:
return self._json(
self._get(
f"/repos/{repo_slug}/doi/",
{"force_refresh": force_refresh},
)
)
@self.mcp.tool()
def get_doi_summary() -> str:
return self._json(self._get("/repos/doi/summary/"))
@self.mcp.tool()
def register_service(
slug: str,
name: str,
provider: str | None = None,
category: str | None = None,
website_url: str | None = None,
pricing_model: str = "unknown",
gdpr_maturity: str = "unknown",
) -> str:
return self._json(
self._post(
"/tpsc/catalog/",
{
"slug": slug,
"name": name,
"provider": provider,
"category": category,
"website_url": website_url,
"pricing_model": pricing_model,
"gdpr_maturity": gdpr_maturity,
},
)
)
@self.mcp.tool()
def list_services(
gdpr_maturity: str | None = None,
category: str | None = None,
pricing_model: str | None = None,
) -> str:
return self._json(
self._get(
"/tpsc/catalog/",
{
"gdpr_maturity": gdpr_maturity,
"category": category,
"pricing_model": pricing_model,
},
)
)
@self.mcp.tool()
def ingest_tpsc_tool(repo_slug: str, source_file: str, entries: list[dict[str, Any]]) -> str:
return self._json(
self._post(
"/tpsc/ingest/",
{"repo_slug": repo_slug, "source_file": source_file, "entries": entries},
)
)
@self.mcp.tool()
def get_gdpr_report() -> str:
return self._json(self._get("/tpsc/report/gdpr/"))
@self.mcp.tool()
def get_risks(
since: str | None = None,
limit: int = 100,
offset: int = 0,
) -> str:
return self._json(
self._get(
"/progress/risks/",
{"since": since, "limit": limit, "offset": offset},
)
)
@self.mcp.tool()
def get_alerts(
since: str | None = None,
limit: int = 100,
offset: int = 0,
) -> str:
return self._json(
self._get(
"/progress/alerts/",
{"since": since, "limit": limit, "offset": offset},
)
)
@self.mcp.tool()
def append_progress(
event_type: str,
summary: str,
detail: dict[str, Any] | None = None,
subject_refs: dict[str, Any] | None = None,
author: str | None = None,
session_id: str | None = None,
) -> str:
return self._json(
self._post(
"/progress/",
{
"event_type": event_type,
"summary": summary,
"detail": detail,
"subject_refs": subject_refs,
"author": author,
"session_id": session_id,
},
)
)
def _get(self, path: str, params: dict[str, Any] | None = None) -> Any:
try:
with self._client() as client:
response = client.get(
normalize_trailing_slash(path),
params=self._clean(params or {}),
)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as exc:
return {"error": f"API {exc.response.status_code}: {exc.response.text[:300]}"}
except Exception as exc:
return {"error": f"Request failed: {exc}"}
def _post(self, path: str, body: dict[str, Any]) -> Any:
try:
with self._client() as client:
response = client.post(normalize_trailing_slash(path), json=self._clean(body))
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as exc:
return {"error": f"API {exc.response.status_code}: {exc.response.text[:300]}"}
except Exception as exc:
return {"error": f"Request failed: {exc}"}
def _patch(self, path: str, body: dict[str, Any]) -> Any:
try:
with self._client() as client:
response = client.patch(normalize_trailing_slash(path), json=self._clean(body))
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as exc:
return {"error": f"API {exc.response.status_code}: {exc.response.text[:300]}"}
except Exception as exc:
return {"error": f"Request failed: {exc}"}
def _client(self) -> httpx.Client:
return httpx.Client(base_url=self.api_base, timeout=30.0, follow_redirects=True)
@staticmethod
def _clean(data: dict[str, Any]) -> dict[str, Any]:
return {key: value for key, value in data.items() if value is not None}
@staticmethod
def _json(data: Any) -> str:
return json.dumps(data, indent=2, default=str)

View File

@@ -0,0 +1 @@
"""Alembic migration templates for hub-core adopters."""

View File

@@ -0,0 +1,49 @@
import os
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from hub_core.models import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
db_url = os.environ.get("DATABASE_URL")
if db_url:
sync_url = db_url.replace("postgresql+asyncpg://", "postgresql+psycopg2://")
config.set_main_option("sqlalchemy.url", sync_url)
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,202 @@
"""hub-core core schema
Revision ID: 0001_core_schema
Revises:
Create Date: 2026-06-06
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0001_core_schema"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"domains",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("slug", sa.String(50), nullable=False, unique=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("description", sa.Text, nullable=True),
sa.Column("status", sa.String(20), nullable=False, server_default="active"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
)
op.create_index("ix_domains_slug", "domains", ["slug"])
op.create_table(
"managed_repos",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("domain_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("domains.id", ondelete="RESTRICT"), nullable=False),
sa.Column("slug", sa.String(100), nullable=False, unique=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("local_path", sa.Text, nullable=True),
sa.Column("host_paths", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="{}"),
sa.Column("remote_url", sa.Text, nullable=True),
sa.Column("description", sa.Text, nullable=True),
sa.Column("status", sa.String(20), nullable=False, server_default="active"),
sa.Column("git_fingerprint", sa.String(40), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
)
op.create_index("ix_managed_repos_domain_id", "managed_repos", ["domain_id"])
op.create_index("ix_managed_repos_git_fingerprint", "managed_repos", ["git_fingerprint"])
op.create_index("ix_managed_repos_slug", "managed_repos", ["slug"])
op.create_table(
"agent_messages",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("from_agent", sa.String(100), nullable=False),
sa.Column("to_agent", sa.String(100), nullable=False),
sa.Column("subject", sa.String(500), nullable=False),
sa.Column("body", sa.Text, nullable=False),
sa.Column("thread_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("agent_messages.id", ondelete="SET NULL"), nullable=True),
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
)
op.create_index("ix_agent_messages_created_at", "agent_messages", ["created_at"])
op.create_index("ix_agent_messages_thread_id", "agent_messages", ["thread_id"])
op.create_index("ix_agent_messages_to_agent_read_at", "agent_messages", ["to_agent", "read_at"])
op.create_table(
"capability_catalog",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("domain_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("domains.id", ondelete="RESTRICT"), nullable=False),
sa.Column("repo_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True),
sa.Column("capability_type", sa.String(50), nullable=False),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("description", sa.Text, nullable=True),
sa.Column("keywords", postgresql.ARRAY(sa.String()), nullable=False, server_default="{}"),
sa.Column("status", sa.String(20), nullable=False, server_default="active"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.UniqueConstraint("domain_id", "capability_type", "title", name="uq_catalog_domain_type_title"),
)
op.create_index("ix_capability_catalog_domain_id", "capability_catalog", ["domain_id"])
op.create_index("ix_capability_catalog_repo_id", "capability_catalog", ["repo_id"])
op.create_table(
"capability_requests",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("title", sa.String(500), nullable=False),
sa.Column("description", sa.Text, nullable=True),
sa.Column("capability_type", sa.String(50), nullable=False),
sa.Column("priority", sa.String(20), nullable=False, server_default="medium"),
sa.Column("status", sa.String(20), nullable=False, server_default="requested"),
sa.Column("requesting_domain_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("domains.id", ondelete="RESTRICT"), nullable=False),
sa.Column("requesting_agent", sa.String(100), nullable=False),
sa.Column("request_context", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("fulfilling_domain_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("domains.id", ondelete="SET NULL"), nullable=True),
sa.Column("fulfilling_agent", sa.String(100), nullable=True),
sa.Column("fulfillment_context", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("catalog_entry_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("capability_catalog.id", ondelete="SET NULL"), nullable=True),
sa.Column("resolution_note", sa.Text, nullable=True),
sa.Column("routing_note", sa.Text, nullable=True),
sa.Column("dispute_reason", sa.Text, nullable=True),
sa.Column("disputed_by", sa.String(100), nullable=True),
sa.Column("dispute_suggested_domain", sa.String(100), nullable=True),
sa.Column("disputed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("accepted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
)
op.create_index("ix_capability_requests_fulfilling_domain_id", "capability_requests", ["fulfilling_domain_id"])
op.create_index("ix_capability_requests_requesting_domain_id", "capability_requests", ["requesting_domain_id"])
op.create_table(
"progress_events",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("event_type", sa.String(50), nullable=False),
sa.Column("summary", sa.Text, nullable=False),
sa.Column("detail", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("subject_refs", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("author", sa.String(100), nullable=True),
sa.Column("session_id", sa.String(100), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
)
op.create_index("ix_progress_events_created_at", "progress_events", ["created_at"])
op.create_index("ix_progress_events_event_type", "progress_events", ["event_type"])
op.create_table(
"tpsc_catalog",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("slug", sa.String(100), nullable=False, unique=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("provider", sa.String(200), nullable=True),
sa.Column("category", sa.String(100), nullable=True),
sa.Column("website_url", sa.Text, nullable=True),
sa.Column("pricing_model", sa.String(20), nullable=False, server_default="unknown"),
sa.Column("gdpr_maturity", sa.String(20), nullable=False, server_default="unknown"),
sa.Column("gdpr_notes", sa.Text, nullable=True),
sa.Column("dpa_available", sa.Boolean, nullable=False, server_default="false"),
sa.Column("tos_url", sa.Text, nullable=True),
sa.Column("privacy_policy_url", sa.Text, nullable=True),
sa.Column("data_processing_regions", postgresql.JSON, nullable=True),
sa.Column("data_retention_notes", sa.Text, nullable=True),
sa.Column("status", sa.String(20), nullable=False, server_default="active"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
)
op.create_index("ix_tpsc_catalog_gdpr_maturity", "tpsc_catalog", ["gdpr_maturity"])
op.create_index("ix_tpsc_catalog_slug", "tpsc_catalog", ["slug"])
op.create_table(
"tpsc_snapshots",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("repo_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True),
sa.Column("snapshot_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("source_file", sa.String(200), nullable=True),
sa.Column("entry_count", sa.Integer, nullable=False, server_default="0"),
)
op.create_index("ix_tpsc_snapshots_repo_id", "tpsc_snapshots", ["repo_id"])
op.create_table(
"tpsc_entries",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("snapshot_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tpsc_snapshots.id", ondelete="CASCADE"), nullable=False),
sa.Column("catalog_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tpsc_catalog.id", ondelete="SET NULL"), nullable=True),
sa.Column("service_slug", sa.String(100), nullable=False),
sa.Column("purpose", sa.Text, nullable=True),
sa.Column("auth_type", sa.String(50), nullable=True),
sa.Column("endpoint_override", sa.Text, nullable=True),
sa.Column("notes", sa.Text, nullable=True),
)
op.create_index("ix_tpsc_entries_service_slug", "tpsc_entries", ["service_slug"])
op.create_index("ix_tpsc_entries_snapshot_id", "tpsc_entries", ["snapshot_id"])
def downgrade() -> None:
op.drop_index("ix_tpsc_entries_snapshot_id", table_name="tpsc_entries")
op.drop_index("ix_tpsc_entries_service_slug", table_name="tpsc_entries")
op.drop_table("tpsc_entries")
op.drop_index("ix_tpsc_snapshots_repo_id", table_name="tpsc_snapshots")
op.drop_table("tpsc_snapshots")
op.drop_index("ix_tpsc_catalog_slug", table_name="tpsc_catalog")
op.drop_index("ix_tpsc_catalog_gdpr_maturity", table_name="tpsc_catalog")
op.drop_table("tpsc_catalog")
op.drop_index("ix_progress_events_event_type", table_name="progress_events")
op.drop_index("ix_progress_events_created_at", table_name="progress_events")
op.drop_table("progress_events")
op.drop_index("ix_capability_requests_requesting_domain_id", table_name="capability_requests")
op.drop_index("ix_capability_requests_fulfilling_domain_id", table_name="capability_requests")
op.drop_table("capability_requests")
op.drop_index("ix_capability_catalog_repo_id", table_name="capability_catalog")
op.drop_index("ix_capability_catalog_domain_id", table_name="capability_catalog")
op.drop_table("capability_catalog")
op.drop_index("ix_agent_messages_to_agent_read_at", table_name="agent_messages")
op.drop_index("ix_agent_messages_thread_id", table_name="agent_messages")
op.drop_index("ix_agent_messages_created_at", table_name="agent_messages")
op.drop_table("agent_messages")
op.drop_index("ix_managed_repos_slug", table_name="managed_repos")
op.drop_index("ix_managed_repos_git_fingerprint", table_name="managed_repos")
op.drop_index("ix_managed_repos_domain_id", table_name="managed_repos")
op.drop_table("managed_repos")
op.drop_index("ix_domains_slug", table_name="domains")
op.drop_table("domains")

View File

@@ -0,0 +1 @@
"""hub-core Alembic version templates."""

View File

@@ -0,0 +1,23 @@
from hub_core.models.agent_message import AgentMessage
from hub_core.models.base import Base, TimestampMixin, new_uuid
from hub_core.models.capability_catalog import CapabilityCatalog
from hub_core.models.capability_request import CapabilityRequest
from hub_core.models.domain import Domain
from hub_core.models.managed_repo import ManagedRepo
from hub_core.models.progress_event import ProgressEvent
from hub_core.models.tpsc import TPSCCatalog, TPSCEntry, TPSCSnapshot
__all__ = [
"AgentMessage",
"Base",
"CapabilityCatalog",
"CapabilityRequest",
"Domain",
"ManagedRepo",
"ProgressEvent",
"TPSCCatalog",
"TPSCEntry",
"TPSCSnapshot",
"TimestampMixin",
"new_uuid",
]

View File

@@ -0,0 +1,44 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from hub_core.models.base import Base, new_uuid
class AgentMessage(Base):
__tablename__ = "agent_messages"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
from_agent: Mapped[str] = mapped_column(String(100), nullable=False)
to_agent: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
subject: Mapped[str] = mapped_column(String(500), nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
thread_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("agent_messages.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
read_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
archived_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=text("now()"),
nullable=False,
)
thread_root: Mapped["AgentMessage | None"] = relationship(
"AgentMessage",
remote_side="AgentMessage.id",
foreign_keys=[thread_id],
lazy="select",
)

25
hub_core/models/base.py Normal file
View File

@@ -0,0 +1,25 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
def new_uuid() -> uuid.UUID:
return uuid.uuid4()

View File

@@ -0,0 +1,50 @@
import uuid
from sqlalchemy import ARRAY, ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from hub_core.models.base import Base, TimestampMixin, new_uuid
class CapabilityCatalog(Base, TimestampMixin):
__tablename__ = "capability_catalog"
__table_args__ = (
UniqueConstraint("domain_id", "capability_type", "title", name="uq_catalog_domain_type_title"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
domain_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("domains.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
repo_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("managed_repos.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
capability_type: Mapped[str] = mapped_column(String(50), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
keywords: Mapped[list[str]] = mapped_column(
ARRAY(String), nullable=False, server_default="{}"
)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="active", server_default="active"
)
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
repo: Mapped["ManagedRepo | None"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
@property
def domain_slug(self) -> str:
return self.domain.slug if self.domain is not None else ""
@property
def repo_slug(self) -> str | None:
return self.repo.slug if self.repo is not None else None

View File

@@ -0,0 +1,76 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from hub_core.models.base import Base, TimestampMixin, new_uuid
class CapabilityRequest(Base, TimestampMixin):
__tablename__ = "capability_requests"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
title: Mapped[str] = mapped_column(String(500), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
capability_type: Mapped[str] = mapped_column(String(50), nullable=False)
priority: Mapped[str] = mapped_column(
String(20), nullable=False, default="medium", server_default="medium"
)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="requested", server_default="requested"
)
requesting_domain_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("domains.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
requesting_agent: Mapped[str] = mapped_column(String(100), nullable=False)
request_context: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
fulfilling_domain_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("domains.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
fulfilling_agent: Mapped[str | None] = mapped_column(String(100), nullable=True)
fulfillment_context: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
catalog_entry_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("capability_catalog.id", ondelete="SET NULL"),
nullable=True,
)
resolution_note: Mapped[str | None] = mapped_column(Text, nullable=True)
routing_note: Mapped[str | None] = mapped_column(Text, nullable=True)
dispute_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
disputed_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
dispute_suggested_domain: Mapped[str | None] = mapped_column(String(100), nullable=True)
disputed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
requesting_domain: Mapped["Domain"] = relationship( # noqa: F821
"Domain", foreign_keys=[requesting_domain_id], lazy="selectin"
)
fulfilling_domain: Mapped["Domain | None"] = relationship( # noqa: F821
"Domain", foreign_keys=[fulfilling_domain_id], lazy="selectin"
)
catalog_entry: Mapped["CapabilityCatalog | None"] = relationship( # noqa: F821
"CapabilityCatalog", lazy="selectin"
)
@property
def requesting_domain_slug(self) -> str:
return self.requesting_domain.slug if self.requesting_domain else ""
@property
def fulfilling_domain_slug(self) -> str | None:
return self.fulfilling_domain.slug if self.fulfilling_domain else None

23
hub_core/models/domain.py Normal file
View File

@@ -0,0 +1,23 @@
import uuid
from sqlalchemy import String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from hub_core.models.base import Base, TimestampMixin, new_uuid
class Domain(Base, TimestampMixin):
__tablename__ = "domains"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
slug: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
repos: Mapped[list["ManagedRepo"]] = relationship( # noqa: F821
"ManagedRepo", back_populates="domain", lazy="selectin"
)

View File

@@ -0,0 +1,37 @@
import uuid
from sqlalchemy import ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from hub_core.models.base import Base, TimestampMixin, new_uuid
class ManagedRepo(Base, TimestampMixin):
__tablename__ = "managed_repos"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
domain_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("domains.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
local_path: Mapped[str | None] = mapped_column(Text, nullable=True)
host_paths: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
remote_url: Mapped[str | None] = mapped_column(Text, nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
git_fingerprint: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
domain: Mapped["Domain"] = relationship( # noqa: F821
"Domain", back_populates="repos", lazy="selectin"
)
@property
def domain_slug(self) -> str:
return self.domain.slug if self.domain is not None else ""

View File

@@ -0,0 +1,27 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from hub_core.models.base import Base, new_uuid
class ProgressEvent(Base):
"""Generic append-only event log for hub activity."""
__tablename__ = "progress_events"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
event_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
summary: Mapped[str] = mapped_column(Text, nullable=False)
detail: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
subject_refs: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
author: Mapped[str | None] = mapped_column(String(100), nullable=True)
session_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
)

78
hub_core/models/tpsc.py Normal file
View File

@@ -0,0 +1,78 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import JSON, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from hub_core.models.base import Base
class TPSCCatalog(Base):
__tablename__ = "tpsc_catalog"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
slug: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
provider: Mapped[str | None] = mapped_column(String(200), nullable=True)
category: Mapped[str | None] = mapped_column(String(100), nullable=True)
website_url: Mapped[str | None] = mapped_column(Text, nullable=True)
pricing_model: Mapped[str] = mapped_column(String(20), nullable=False, server_default="unknown")
gdpr_maturity: Mapped[str] = mapped_column(
String(20), nullable=False, server_default="unknown", index=True
)
gdpr_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
dpa_available: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
tos_url: Mapped[str | None] = mapped_column(Text, nullable=True)
privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True)
data_processing_regions: Mapped[list | None] = mapped_column(JSON, nullable=True)
data_retention_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="active")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
entries: Mapped[list["TPSCEntry"]] = relationship("TPSCEntry", back_populates="catalog_entry")
class TPSCSnapshot(Base):
__tablename__ = "tpsc_snapshots"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
repo_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("managed_repos.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
snapshot_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
source_file: Mapped[str | None] = mapped_column(String(200), nullable=True)
entry_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0")
entries: Mapped[list["TPSCEntry"]] = relationship(
"TPSCEntry", back_populates="snapshot", cascade="all, delete-orphan"
)
class TPSCEntry(Base):
__tablename__ = "tpsc_entries"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
snapshot_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tpsc_snapshots.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
catalog_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tpsc_catalog.id", ondelete="SET NULL"), nullable=True
)
service_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
purpose: Mapped[str | None] = mapped_column(Text, nullable=True)
auth_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
endpoint_override: Mapped[str | None] = mapped_column(Text, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
snapshot: Mapped["TPSCSnapshot"] = relationship("TPSCSnapshot", back_populates="entries")
catalog_entry: Mapped["TPSCCatalog | None"] = relationship("TPSCCatalog", back_populates="entries")

View File

@@ -0,0 +1,23 @@
from hub_core.routers.capabilities import (
create_capabilities_router,
create_capability_catalog_router,
create_capability_request_read_router,
)
from hub_core.routers.domains import create_domains_router
from hub_core.routers.messages import create_messages_router
from hub_core.routers.policy import create_policy_router
from hub_core.routers.progress import create_progress_router
from hub_core.routers.repos import create_repos_router
from hub_core.routers.tpsc import create_tpsc_router
__all__ = [
"create_capabilities_router",
"create_capability_catalog_router",
"create_capability_request_read_router",
"create_domains_router",
"create_messages_router",
"create_policy_router",
"create_progress_router",
"create_repos_router",
"create_tpsc_router",
]

View File

@@ -0,0 +1,427 @@
import uuid
from collections.abc import Callable
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from hub_core.models.capability_catalog import CapabilityCatalog
from hub_core.models.capability_request import CapabilityRequest
from hub_core.models.domain import Domain
from hub_core.models.managed_repo import ManagedRepo
from hub_core.schemas.capability import (
CapabilityRequestAccept,
CapabilityRequestCreate,
CapabilityRequestDispute,
CapabilityRequestPatch,
CapabilityRequestRead,
CapabilityRequestStatusPatch,
CatalogCreate,
CatalogPatch,
CatalogRead,
)
def create_capability_catalog_router(
get_session: Callable[..., AsyncSession],
*,
domain_model: type[Domain] = Domain,
repo_model: type[ManagedRepo] = ManagedRepo,
catalog_model: type[CapabilityCatalog] = CapabilityCatalog,
catalog_create_schema: type[CatalogCreate] = CatalogCreate,
catalog_patch_schema: type[CatalogPatch] = CatalogPatch,
catalog_read_schema: type[CatalogRead] = CatalogRead,
) -> APIRouter:
router = APIRouter(tags=["capability-requests"])
list_response_model = list[catalog_read_schema]
@router.post("/capability-catalog/", response_model=catalog_read_schema, status_code=status.HTTP_201_CREATED)
async def create_catalog_entry(
body: catalog_create_schema,
session: AsyncSession = Depends(get_session),
) -> Any:
domain = await _resolve_domain(body.domain, session, domain_model)
repo_id = None
if body.repo_slug:
repo = await _resolve_repo(body.repo_slug, session, repo_model)
repo_id = repo.id
entry = catalog_model(
domain_id=domain.id,
repo_id=repo_id,
capability_type=body.capability_type,
title=body.title,
description=body.description,
keywords=body.keywords,
)
session.add(entry)
try:
await session.commit()
except Exception:
await session.rollback()
raise HTTPException(
status_code=409,
detail=(
f"Catalog entry '{body.title}' for type '{body.capability_type}' "
f"already exists in domain '{body.domain}'"
),
)
await session.refresh(entry)
return entry
@router.get("/capability-catalog/", response_model=list_response_model)
async def list_catalog(
domain: str | None = Query(None),
capability_type: str | None = Query(None),
status_filter: str | None = Query(None, alias="status"),
session: AsyncSession = Depends(get_session),
) -> list[Any]:
q = select(catalog_model).order_by(catalog_model.created_at.desc())
if domain:
domain_obj = await _resolve_domain(domain, session, domain_model)
q = q.where(catalog_model.domain_id == domain_obj.id)
if capability_type:
q = q.where(catalog_model.capability_type == capability_type)
if status_filter and status_filter != "all":
q = q.where(catalog_model.status == status_filter)
elif not status_filter:
q = q.where(catalog_model.status == "active")
result = await session.execute(q)
return list(result.scalars().all())
@router.patch("/capability-catalog/{entry_id}", response_model=catalog_read_schema)
async def patch_catalog_entry(
entry_id: uuid.UUID,
body: catalog_patch_schema,
session: AsyncSession = Depends(get_session),
) -> Any:
entry = await session.get(catalog_model, entry_id)
if entry is None:
raise HTTPException(status_code=404, detail=f"Catalog entry '{entry_id}' not found")
if body.repo_slug is not None:
repo = await _resolve_repo(body.repo_slug, session, repo_model)
entry.repo_id = repo.id
if body.description is not None:
entry.description = body.description
if body.keywords is not None:
entry.keywords = body.keywords
if body.status is not None:
entry.status = body.status
await session.commit()
await session.refresh(entry)
return entry
return router
def create_capability_request_read_router(
get_session: Callable[..., AsyncSession],
*,
domain_model: type[Domain] = Domain,
request_model: type[CapabilityRequest] = CapabilityRequest,
request_read_schema: type[CapabilityRequestRead] = CapabilityRequestRead,
) -> APIRouter:
router = APIRouter(tags=["capability-requests"])
list_response_model = list[request_read_schema]
@router.get("/capability-requests/", response_model=list_response_model)
async def list_requests(
domain: str | None = Query(
None,
description="Filter by requesting or fulfilling domain slug",
),
status_filter: str | None = Query(None, alias="status"),
capability_type: str | None = Query(None),
session: AsyncSession = Depends(get_session),
) -> list[Any]:
q = select(request_model).order_by(request_model.created_at.desc())
if domain:
domain_obj = await _resolve_domain(domain, session, domain_model)
q = q.where(
(request_model.requesting_domain_id == domain_obj.id)
| (request_model.fulfilling_domain_id == domain_obj.id)
)
if status_filter:
q = q.where(request_model.status == status_filter)
if capability_type:
q = q.where(request_model.capability_type == capability_type)
result = await session.execute(q)
return list(result.scalars().all())
@router.get("/capability-requests/{request_id}", response_model=request_read_schema)
async def get_request(
request_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Any:
req = await session.get(request_model, request_id)
if req is None:
raise HTTPException(status_code=404, detail=f"Capability request '{request_id}' not found")
return req
return router
def create_capabilities_router(get_session: Callable[..., AsyncSession]) -> APIRouter:
router = APIRouter(tags=["capability-requests"])
@router.post("/capability-catalog/", response_model=CatalogRead, status_code=status.HTTP_201_CREATED)
async def create_catalog_entry(
body: CatalogCreate,
session: AsyncSession = Depends(get_session),
) -> CapabilityCatalog:
domain = await _resolve_domain(body.domain, session, Domain)
repo_id = None
if body.repo_slug:
repo = await _resolve_repo(body.repo_slug, session, ManagedRepo)
repo_id = repo.id
entry = CapabilityCatalog(
domain_id=domain.id,
repo_id=repo_id,
capability_type=body.capability_type,
title=body.title,
description=body.description,
keywords=body.keywords,
)
session.add(entry)
try:
await session.commit()
except Exception:
await session.rollback()
raise HTTPException(
status_code=409,
detail=(
f"Catalog entry '{body.title}' for type '{body.capability_type}' "
f"already exists in domain '{body.domain}'"
),
)
await session.refresh(entry)
return entry
@router.get("/capability-catalog/", response_model=list[CatalogRead])
async def list_catalog(
domain: str | None = Query(None),
capability_type: str | None = Query(None),
status_filter: str | None = Query(None, alias="status"),
session: AsyncSession = Depends(get_session),
) -> list[CapabilityCatalog]:
q = select(CapabilityCatalog).order_by(CapabilityCatalog.created_at.desc())
if domain:
domain_obj = await _resolve_domain(domain, session, Domain)
q = q.where(CapabilityCatalog.domain_id == domain_obj.id)
if capability_type:
q = q.where(CapabilityCatalog.capability_type == capability_type)
if status_filter and status_filter != "all":
q = q.where(CapabilityCatalog.status == status_filter)
elif not status_filter:
q = q.where(CapabilityCatalog.status == "active")
result = await session.execute(q)
return list(result.scalars().all())
@router.patch("/capability-catalog/{entry_id}", response_model=CatalogRead)
async def patch_catalog_entry(
entry_id: uuid.UUID,
body: CatalogPatch,
session: AsyncSession = Depends(get_session),
) -> CapabilityCatalog:
entry = await session.get(CapabilityCatalog, entry_id)
if entry is None:
raise HTTPException(status_code=404, detail=f"Catalog entry '{entry_id}' not found")
if body.repo_slug is not None:
repo = await _resolve_repo(body.repo_slug, session, ManagedRepo)
entry.repo_id = repo.id
if body.description is not None:
entry.description = body.description
if body.keywords is not None:
entry.keywords = body.keywords
if body.status is not None:
entry.status = body.status
await session.commit()
await session.refresh(entry)
return entry
@router.post("/capability-requests/", response_model=CapabilityRequestRead, status_code=status.HTTP_201_CREATED)
async def create_request(
body: CapabilityRequestCreate,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
requesting_domain = await _resolve_domain(body.requesting_domain, session, Domain)
fulfilling_domain_id = None
catalog_entry_id = body.catalog_entry_id
routing_note = None
if catalog_entry_id:
catalog_entry = await _resolve_catalog_entry(catalog_entry_id, session)
fulfilling_domain_id = catalog_entry.domain_id
routing_note = "Routed by explicit catalog entry."
else:
catalog_entry = await _find_catalog_route(body.capability_type, session)
if catalog_entry:
catalog_entry_id = catalog_entry.id
fulfilling_domain_id = catalog_entry.domain_id
routing_note = "Routed by first active catalog match for capability_type."
req = CapabilityRequest(
title=body.title,
description=body.description,
capability_type=body.capability_type,
priority=body.priority,
requesting_domain_id=requesting_domain.id,
requesting_agent=body.requesting_agent,
request_context=body.request_context,
fulfilling_domain_id=fulfilling_domain_id,
catalog_entry_id=catalog_entry_id,
routing_note=routing_note,
)
session.add(req)
await session.commit()
await session.refresh(req)
return req
@router.get("/capability-requests/", response_model=list[CapabilityRequestRead])
async def list_requests(
domain: str | None = Query(None, description="Filter by requesting or fulfilling domain slug"),
status_filter: str | None = Query(None, alias="status"),
capability_type: str | None = Query(None),
session: AsyncSession = Depends(get_session),
) -> list[CapabilityRequest]:
q = select(CapabilityRequest).order_by(CapabilityRequest.created_at.desc())
if domain:
domain_obj = await _resolve_domain(domain, session, Domain)
q = q.where(
(CapabilityRequest.requesting_domain_id == domain_obj.id)
| (CapabilityRequest.fulfilling_domain_id == domain_obj.id)
)
if status_filter:
q = q.where(CapabilityRequest.status == status_filter)
if capability_type:
q = q.where(CapabilityRequest.capability_type == capability_type)
result = await session.execute(q)
return list(result.scalars().all())
@router.get("/capability-requests/{request_id}", response_model=CapabilityRequestRead)
async def get_request(
request_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
return await _get_request_or_404(request_id, session)
@router.post("/capability-requests/{request_id}/accept", response_model=CapabilityRequestRead)
async def accept_request(
request_id: uuid.UUID,
body: CapabilityRequestAccept,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
req = await _get_request_or_404(request_id, session)
req.status = "accepted"
req.fulfilling_agent = body.fulfilling_agent
req.fulfillment_context = body.fulfillment_context
req.accepted_at = datetime.now(tz=timezone.utc)
await session.commit()
await session.refresh(req)
return req
@router.patch("/capability-requests/{request_id}", response_model=CapabilityRequestRead)
async def patch_request(
request_id: uuid.UUID,
body: CapabilityRequestPatch,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
req = await _get_request_or_404(request_id, session)
if body.catalog_entry_id is not None:
catalog_entry = await _resolve_catalog_entry(body.catalog_entry_id, session)
req.catalog_entry_id = catalog_entry.id
req.fulfilling_domain_id = catalog_entry.domain_id
if body.priority is not None:
req.priority = body.priority
if body.request_context is not None:
req.request_context = body.request_context
if body.fulfillment_context is not None:
req.fulfillment_context = body.fulfillment_context
await session.commit()
await session.refresh(req)
return req
@router.patch("/capability-requests/{request_id}/status", response_model=CapabilityRequestRead)
async def patch_request_status(
request_id: uuid.UUID,
body: CapabilityRequestStatusPatch,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
req = await _get_request_or_404(request_id, session)
req.status = body.status
if body.note:
req.resolution_note = body.note
if body.status == "completed":
req.completed_at = datetime.now(tz=timezone.utc)
await session.commit()
await session.refresh(req)
return req
@router.post("/capability-requests/{request_id}/dispute", response_model=CapabilityRequestRead)
async def dispute_request(
request_id: uuid.UUID,
body: CapabilityRequestDispute,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
req = await _get_request_or_404(request_id, session)
req.status = "routing_disputed"
req.dispute_reason = body.reason
req.disputed_by = body.disputed_by
req.dispute_suggested_domain = body.suggested_domain
req.disputed_at = datetime.now(tz=timezone.utc)
await session.commit()
await session.refresh(req)
return req
return router
async def _resolve_domain(
slug: str,
session: AsyncSession,
domain_model: type[Domain],
) -> Any:
result = await session.execute(select(domain_model).where(domain_model.slug == slug))
domain = result.scalar_one_or_none()
if domain is None:
raise HTTPException(status_code=404, detail=f"Domain '{slug}' not found")
return domain
async def _resolve_repo(
slug: str,
session: AsyncSession,
repo_model: type[ManagedRepo],
) -> Any:
result = await session.execute(select(repo_model).where(repo_model.slug == slug))
repo = result.scalar_one_or_none()
if repo is None:
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
return repo
async def _resolve_catalog_entry(entry_id: uuid.UUID, session: AsyncSession) -> CapabilityCatalog:
entry = await session.get(CapabilityCatalog, entry_id)
if entry is None:
raise HTTPException(status_code=404, detail=f"Catalog entry '{entry_id}' not found")
return entry
async def _find_catalog_route(
capability_type: str,
session: AsyncSession,
) -> CapabilityCatalog | None:
result = await session.execute(
select(CapabilityCatalog)
.where(CapabilityCatalog.capability_type == capability_type)
.where(CapabilityCatalog.status == "active")
.order_by(CapabilityCatalog.created_at.desc())
)
return result.scalars().first()
async def _get_request_or_404(request_id: uuid.UUID, session: AsyncSession) -> CapabilityRequest:
req = await session.get(CapabilityRequest, request_id)
if req is None:
raise HTTPException(status_code=404, detail=f"Capability request '{request_id}' not found")
return req

178
hub_core/routers/domains.py Normal file
View File

@@ -0,0 +1,178 @@
from collections.abc import Callable
from typing import Any, Awaitable
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload
from hub_core.models.domain import Domain
from hub_core.models.managed_repo import ManagedRepo
from hub_core.schemas.domain import DomainCreate, DomainDetail, DomainRead, DomainRename, DomainUpdate, RepoStub
DomainDetailBuilder = Callable[[Any, AsyncSession], Awaitable[Any]]
DomainArchiveValidator = Callable[[Any, AsyncSession], Awaitable[None]]
def create_domains_router(
get_session: Callable[..., AsyncSession],
*,
domain_model: type[Domain] = Domain,
repo_model: type[ManagedRepo] = ManagedRepo,
domain_create_schema: type[DomainCreate] = DomainCreate,
domain_detail_schema: type[DomainDetail] = DomainDetail,
domain_read_schema: type[DomainRead] = DomainRead,
domain_rename_schema: type[DomainRename] = DomainRename,
domain_update_schema: type[DomainUpdate] = DomainUpdate,
repo_stub_schema: type[RepoStub] = RepoStub,
detail_builder: DomainDetailBuilder | None = None,
before_archive: DomainArchiveValidator | None = None,
list_noload_fields: tuple[str, ...] = ("repos",),
include_update_route: bool = True,
) -> APIRouter:
router = APIRouter(prefix="/domains", tags=["domains"])
list_response_model = list[domain_read_schema]
@router.get("/", response_model=list_response_model)
async def list_domains(
response: Response,
status_filter: str | None = Query(None, alias="status", description="active | archived | all"),
session: AsyncSession = Depends(get_session),
) -> list[Any]:
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
q = select(domain_model).options(
*[
noload(getattr(domain_model, field))
for field in list_noload_fields
if hasattr(domain_model, field)
]
).order_by(domain_model.name)
if status_filter and status_filter != "all":
q = q.where(domain_model.status == status_filter)
elif status_filter is None:
q = q.where(domain_model.status == "active")
result = await session.execute(q)
return list(result.scalars().all())
@router.post("/", response_model=domain_read_schema, status_code=status.HTTP_201_CREATED)
async def create_domain(
body: domain_create_schema,
session: AsyncSession = Depends(get_session),
) -> Any:
existing = await session.execute(select(domain_model).where(domain_model.slug == body.slug))
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail=f"Domain slug '{body.slug}' already exists")
domain = domain_model(slug=body.slug, name=body.name, description=body.description)
session.add(domain)
await session.commit()
await session.refresh(domain)
return domain
@router.get("/{slug}", response_model=domain_detail_schema)
async def get_domain(
slug: str,
session: AsyncSession = Depends(get_session),
) -> Any:
domain = await _get_domain_by_slug(slug, session, domain_model)
if detail_builder is not None:
return await detail_builder(domain, session)
return await _build_default_domain_detail(
domain,
session,
repo_model=repo_model,
repo_stub_schema=repo_stub_schema,
domain_detail_schema=domain_detail_schema,
)
if include_update_route:
@router.patch("/{slug}", response_model=domain_read_schema)
async def update_domain(
slug: str,
body: domain_update_schema,
session: AsyncSession = Depends(get_session),
) -> Any:
domain = await _get_domain_by_slug(slug, session, domain_model)
for field, value in body.model_dump(exclude_unset=True).items():
setattr(domain, field, value)
await session.commit()
await session.refresh(domain)
return domain
@router.patch("/{slug}/rename", response_model=domain_read_schema)
async def rename_domain(
slug: str,
body: domain_rename_schema,
session: AsyncSession = Depends(get_session),
) -> Any:
domain = await _get_domain_by_slug(slug, session, domain_model)
if body.new_slug != slug:
conflict = await session.execute(select(domain_model).where(domain_model.slug == body.new_slug))
if conflict.scalar_one_or_none():
raise HTTPException(status_code=409, detail=f"Slug '{body.new_slug}' already taken")
domain.slug = body.new_slug
domain.name = body.new_name
await session.commit()
await session.refresh(domain)
return domain
@router.patch("/{slug}/archive", response_model=domain_read_schema)
async def archive_domain(
slug: str,
session: AsyncSession = Depends(get_session),
) -> Any:
domain = await _get_domain_by_slug(slug, session, domain_model)
if before_archive is not None:
await before_archive(domain, session)
domain.status = "archived"
await session.commit()
await session.refresh(domain)
return domain
return router
async def _get_domain_by_slug(
slug: str,
session: AsyncSession,
domain_model: type[Domain],
) -> Any:
result = await session.execute(select(domain_model).where(domain_model.slug == slug))
domain = result.scalar_one_or_none()
if domain is None:
raise HTTPException(status_code=404, detail=f"Domain '{slug}' not found")
return domain
async def _build_default_domain_detail(
domain: Any,
session: AsyncSession,
*,
repo_model: type[ManagedRepo],
repo_stub_schema: type[RepoStub],
domain_detail_schema: type[DomainDetail],
) -> Any:
repo_count_row = await session.execute(
select(func.count()).select_from(repo_model)
.where(repo_model.domain_id == domain.id)
.where(repo_model.status == "active")
)
repos_row = await session.execute(
select(repo_model)
.where(repo_model.domain_id == domain.id)
.where(repo_model.status == "active")
.order_by(repo_model.name)
)
repos = list(repos_row.scalars().all())
return domain_detail_schema(
id=domain.id,
slug=domain.slug,
name=domain.name,
description=domain.description,
status=domain.status,
created_at=domain.created_at,
updated_at=domain.updated_at,
repos=[repo_stub_schema.model_validate(repo) for repo in repos],
extension_counts={"repos": repo_count_row.scalar_one()},
)

View File

@@ -0,0 +1,121 @@
import uuid
from collections.abc import Callable
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from hub_core.models.agent_message import AgentMessage
from hub_core.schemas.agent_message import MessageCreate, MessageRead, MessageReply
def create_messages_router(
get_session: Callable[..., AsyncSession],
*,
message_model: type[AgentMessage] = AgentMessage,
) -> APIRouter:
router = APIRouter(prefix="/messages", tags=["messages"])
async def _get_message(message_id: uuid.UUID, session: AsyncSession) -> Any:
msg = await session.get(message_model, message_id)
if msg is None:
raise HTTPException(status_code=404, detail=f"Message {message_id} not found")
return msg
@router.post("/", response_model=MessageRead, status_code=status.HTTP_201_CREATED)
async def send_message(
body: MessageCreate,
session: AsyncSession = Depends(get_session),
) -> Any:
if body.thread_id:
root = await session.get(message_model, body.thread_id)
if root is None:
raise HTTPException(status_code=404, detail=f"Thread root {body.thread_id} not found")
msg = message_model(**body.model_dump())
session.add(msg)
await session.commit()
await session.refresh(msg)
return msg
@router.get("/", response_model=list[MessageRead])
async def list_messages(
to_agent: str | None = None,
from_agent: str | None = None,
unread_only: bool = False,
limit: int = 50,
session: AsyncSession = Depends(get_session),
) -> list[Any]:
q = select(message_model).where(message_model.archived_at.is_(None))
if to_agent:
q = q.where(
(message_model.to_agent == to_agent) | (message_model.to_agent == "broadcast")
)
if from_agent:
q = q.where(message_model.from_agent == from_agent)
if unread_only:
q = q.where(message_model.read_at.is_(None))
q = q.order_by(message_model.created_at.desc()).limit(limit)
result = await session.execute(q)
return list(result.scalars().all())
@router.get("/thread/{thread_id}", response_model=list[MessageRead])
async def get_thread(
thread_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> list[Any]:
q = select(message_model).where(
(message_model.id == thread_id) | (message_model.thread_id == thread_id)
).order_by(message_model.created_at)
result = await session.execute(q)
return list(result.scalars().all())
@router.patch("/{message_id}/read", response_model=MessageRead)
async def mark_read(
message_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Any:
msg = await _get_message(message_id, session)
if msg.read_at is None:
msg.read_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(msg)
return msg
@router.patch("/{message_id}/archive", response_model=MessageRead)
async def archive_message(
message_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Any:
msg = await _get_message(message_id, session)
msg.archived_at = datetime.now(timezone.utc)
if msg.read_at is None:
msg.read_at = msg.archived_at
await session.commit()
await session.refresh(msg)
return msg
@router.post("/{message_id}/reply", response_model=MessageRead, status_code=status.HTTP_201_CREATED)
async def reply_to_message(
message_id: uuid.UUID,
body: MessageReply,
session: AsyncSession = Depends(get_session),
) -> Any:
original = await _get_message(message_id, session)
if original.read_at is None:
original.read_at = datetime.now(timezone.utc)
thread_root = original.thread_id or original.id
reply = message_model(
from_agent=body.from_agent,
to_agent=original.from_agent,
subject=f"Re: {original.subject}",
body=body.body,
thread_id=thread_root,
)
session.add(reply)
await session.commit()
await session.refresh(reply)
return reply
return router

View File

@@ -0,0 +1,30 @@
from collections.abc import Callable
from fastapi import APIRouter, HTTPException
from hub_core.schemas.policy import PolicyRead, PolicyUpdate
PolicyLoader = Callable[[str], PolicyRead | None]
PolicyUpdater = Callable[[str, str], PolicyRead]
def create_policy_router(
load_policy: PolicyLoader,
update_policy: PolicyUpdater | None = None,
) -> APIRouter:
router = APIRouter(prefix="/policy", tags=["policy"])
@router.get("/{name}", response_model=PolicyRead)
def get_policy(name: str) -> PolicyRead:
policy = load_policy(name)
if policy is None:
raise HTTPException(status_code=404, detail=f"Policy '{name}' not found")
return policy
if update_policy is not None:
@router.put("/{name}", response_model=PolicyRead)
def put_policy(name: str, body: PolicyUpdate) -> PolicyRead:
return update_policy(name, body.content)
return router

View File

@@ -0,0 +1,130 @@
import uuid
from collections.abc import Callable, Collection
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from hub_core.events import ALERT_EVENT_TYPES, RISK_EVENT_TYPES
from hub_core.models.progress_event import ProgressEvent
from hub_core.schemas.progress_event import ProgressEventCreate, ProgressEventRead
from hub_core.utils.pagination import PageParams, apply_pagination
def create_progress_router(
get_session: Callable[..., AsyncSession],
*,
progress_model: type[ProgressEvent] = ProgressEvent,
progress_create_schema: type[ProgressEventCreate] = ProgressEventCreate,
progress_read_schema: type[ProgressEventRead] = ProgressEventRead,
) -> APIRouter:
router = APIRouter(prefix="/progress", tags=["progress"])
list_response_model = list[progress_read_schema]
async def _list_events(
session: AsyncSession,
*,
topic_id: uuid.UUID | None = None,
workstream_id: uuid.UUID | None = None,
task_id: uuid.UUID | None = None,
decision_id: uuid.UUID | None = None,
event_type: str | None = None,
event_types: Collection[str] | None = None,
since: datetime | None = None,
limit: int = 100,
offset: int = 0,
) -> list[Any]:
q = select(progress_model)
for field, value in (
("topic_id", topic_id),
("workstream_id", workstream_id),
("task_id", task_id),
("decision_id", decision_id),
):
if value is not None:
column = getattr(progress_model, field, None)
if column is None:
raise HTTPException(
status_code=400,
detail=f"Progress events do not support filtering by {field}",
)
q = q.where(column == value)
if event_type:
q = q.where(progress_model.event_type == event_type)
if event_types is not None:
q = q.where(progress_model.event_type.in_(sorted(event_types)))
if since:
q = q.where(progress_model.created_at >= since)
q = q.order_by(progress_model.created_at.desc())
q = apply_pagination(q, PageParams(limit=limit, offset=offset))
result = await session.execute(q)
return list(result.scalars().all())
@router.get("/", response_model=list_response_model)
async def list_progress(
topic_id: uuid.UUID | None = None,
workstream_id: uuid.UUID | None = None,
task_id: uuid.UUID | None = None,
decision_id: uuid.UUID | None = None,
event_type: str | None = None,
since: datetime | None = None,
limit: int = Query(100, le=1000),
offset: int = Query(0, ge=0),
session: AsyncSession = Depends(get_session),
) -> list[Any]:
return await _list_events(
session,
topic_id=topic_id,
workstream_id=workstream_id,
task_id=task_id,
decision_id=decision_id,
event_type=event_type,
since=since,
limit=limit,
offset=offset,
)
@router.get("/risks", response_model=list_response_model)
async def get_risks(
since: datetime | None = None,
limit: int = Query(100, le=1000),
offset: int = Query(0, ge=0),
session: AsyncSession = Depends(get_session),
) -> list[Any]:
return await _list_events(
session,
event_types=RISK_EVENT_TYPES,
since=since,
limit=limit,
offset=offset,
)
@router.get("/alerts", response_model=list_response_model)
async def get_alerts(
since: datetime | None = None,
limit: int = Query(100, le=1000),
offset: int = Query(0, ge=0),
session: AsyncSession = Depends(get_session),
) -> list[Any]:
return await _list_events(
session,
event_types=ALERT_EVENT_TYPES,
since=since,
limit=limit,
offset=offset,
)
@router.post("/", response_model=progress_read_schema, status_code=status.HTTP_201_CREATED)
async def append_progress(
body: progress_create_schema,
session: AsyncSession = Depends(get_session),
) -> Any:
event = progress_model(**body.model_dump())
session.add(event)
await session.commit()
await session.refresh(event)
return event
return router

173
hub_core/routers/repos.py Normal file
View File

@@ -0,0 +1,173 @@
from collections.abc import Callable
from typing import Any, Awaitable
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload
from hub_core.models.domain import Domain
from hub_core.models.managed_repo import ManagedRepo
from hub_core.schemas.managed_repo import RepoCreate, RepoPathRegister, RepoRead, RepoUpdate
RepoRegisteredHook = Callable[[Any, Any, Any], Awaitable[None] | None]
def create_repos_router(
get_session: Callable[..., AsyncSession],
*,
prefix: str = "/repos",
domain_model: type[Domain] = Domain,
repo_model: type[ManagedRepo] = ManagedRepo,
repo_create_schema: type[RepoCreate] = RepoCreate,
repo_update_schema: type[RepoUpdate] = RepoUpdate,
repo_read_schema: type[RepoRead] = RepoRead,
repo_path_register_schema: type[RepoPathRegister] = RepoPathRegister,
list_noload_fields: tuple[str, ...] = ("domain",),
create_extension_fields: tuple[str, ...] = (),
after_register: RepoRegisteredHook | None = None,
include_collection_routes: bool = True,
include_lookup_routes: bool = True,
include_slug_routes: bool = True,
) -> APIRouter:
router = APIRouter(prefix=prefix, tags=["repos"])
list_response_model = list[repo_read_schema]
if include_collection_routes:
@router.get("/", response_model=list_response_model)
async def list_repos(
response: Response,
domain: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[Any]:
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
q = select(repo_model).options(
*[
noload(getattr(repo_model, field))
for field in list_noload_fields
if hasattr(repo_model, field)
]
).order_by(repo_model.name)
if domain:
domain_obj = await _get_domain_by_slug(domain, session, domain_model)
q = q.where(repo_model.domain_id == domain_obj.id)
result = await session.execute(q)
return list(result.scalars().all())
@router.post("/", response_model=repo_read_schema, status_code=status.HTTP_201_CREATED)
async def register_repo(
body: repo_create_schema,
session: AsyncSession = Depends(get_session),
) -> Any:
domain_obj = await _get_domain_by_slug(body.domain_slug, session, domain_model)
existing = await session.execute(select(repo_model).where(repo_model.slug == body.slug))
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail=f"Repo slug '{body.slug}' already exists")
repo_attrs = {
"domain_id": domain_obj.id,
"slug": body.slug,
"name": body.name,
"local_path": body.local_path,
"host_paths": body.host_paths,
"remote_url": body.remote_url,
"git_fingerprint": body.git_fingerprint,
"description": body.description,
}
for field in create_extension_fields:
if hasattr(body, field) and hasattr(repo_model, field):
repo_attrs[field] = getattr(body, field)
repo = repo_model(**repo_attrs)
session.add(repo)
await session.commit()
await session.refresh(repo)
if after_register is not None:
hook_result = after_register(repo, body, domain_obj)
if hook_result is not None:
await hook_result
return repo
if include_lookup_routes:
@router.get("/by-fingerprint", response_model=list_response_model)
async def get_repo_by_fingerprint(
hash: str,
remote_url: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[Any]:
q = select(repo_model).where(repo_model.git_fingerprint == hash)
if remote_url:
q = q.where(repo_model.remote_url == remote_url)
result = await session.execute(q)
return list(result.scalars().all())
@router.get("/by-remote", response_model=repo_read_schema)
async def get_repo_by_remote_url(
url: str,
session: AsyncSession = Depends(get_session),
) -> Any:
result = await session.execute(select(repo_model).where(repo_model.remote_url == url))
repo = result.scalar_one_or_none()
if repo is None:
raise HTTPException(status_code=404, detail=f"No repo with remote_url '{url}' found")
return repo
if include_slug_routes:
@router.get("/{slug}", response_model=repo_read_schema)
async def get_repo(
slug: str,
session: AsyncSession = Depends(get_session),
) -> Any:
return await _get_repo_by_slug(slug, session, repo_model)
@router.patch("/{slug}", response_model=repo_read_schema)
async def update_repo(
slug: str,
body: repo_update_schema,
session: AsyncSession = Depends(get_session),
) -> Any:
repo = await _get_repo_by_slug(slug, session, repo_model)
for field, value in body.model_dump(exclude_unset=True).items():
setattr(repo, field, value)
await session.commit()
await session.refresh(repo)
return repo
@router.post("/{slug}/paths", response_model=repo_read_schema)
async def register_repo_path(
slug: str,
body: repo_path_register_schema,
session: AsyncSession = Depends(get_session),
) -> Any:
repo = await _get_repo_by_slug(slug, session, repo_model)
host_paths = dict(repo.host_paths or {})
host_paths[body.host] = body.path
repo.host_paths = host_paths
await session.commit()
await session.refresh(repo)
return repo
return router
async def _get_domain_by_slug(
slug: str,
session: AsyncSession,
domain_model: type[Domain],
) -> Any:
result = await session.execute(select(domain_model).where(domain_model.slug == slug))
domain = result.scalar_one_or_none()
if domain is None:
raise HTTPException(status_code=404, detail=f"Domain '{slug}' not found")
return domain
async def _get_repo_by_slug(
slug: str,
session: AsyncSession,
repo_model: type[ManagedRepo],
) -> Any:
result = await session.execute(select(repo_model).where(repo_model.slug == slug))
repo = result.scalar_one_or_none()
if repo is None:
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
return repo

240
hub_core/routers/tpsc.py Normal file
View File

@@ -0,0 +1,240 @@
from collections.abc import Callable
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from hub_core.models.managed_repo import ManagedRepo
from hub_core.models.tpsc import TPSCCatalog, TPSCEntry, TPSCSnapshot
from hub_core.schemas.tpsc import (
GDPR_WARNING_LEVELS,
TPSCCatalogCreate,
TPSCCatalogRead,
TPSCEntryRead,
TPSCGDPRReport,
TPSCGDPRWarning,
TPSCIngestRequest,
TPSCSnapshotRead,
)
def create_tpsc_router(
get_session: Callable[..., AsyncSession],
*,
repo_model: type[ManagedRepo] = ManagedRepo,
catalog_model: type[TPSCCatalog] = TPSCCatalog,
snapshot_model: type[TPSCSnapshot] = TPSCSnapshot,
entry_model: type[TPSCEntry] = TPSCEntry,
) -> APIRouter:
router = APIRouter(prefix="/tpsc", tags=["tpsc"])
@router.get("/catalog/", response_model=list[TPSCCatalogRead])
async def list_catalog(
gdpr_maturity: str | None = None,
category: str | None = None,
pricing_model: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[Any]:
q = select(catalog_model).where(catalog_model.status != "deprecated")
if gdpr_maturity:
q = q.where(catalog_model.gdpr_maturity == gdpr_maturity)
if category:
q = q.where(catalog_model.category == category)
if pricing_model:
q = q.where(catalog_model.pricing_model == pricing_model)
q = q.order_by(catalog_model.name)
result = await session.execute(q)
return list(result.scalars().all())
@router.get("/catalog/{slug}", response_model=TPSCCatalogRead)
async def get_catalog_entry(
slug: str,
session: AsyncSession = Depends(get_session),
) -> Any:
row = (
await session.execute(select(catalog_model).where(catalog_model.slug == slug))
).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail=f"Service '{slug}' not found in catalog")
return row
@router.post("/catalog/", response_model=TPSCCatalogRead, status_code=status.HTTP_201_CREATED)
async def register_service(
body: TPSCCatalogCreate,
session: AsyncSession = Depends(get_session),
) -> Any:
existing = (
await session.execute(select(catalog_model).where(catalog_model.slug == body.slug))
).scalar_one_or_none()
if existing:
for field, value in body.model_dump(exclude_unset=True).items():
setattr(existing, field, value)
existing.updated_at = datetime.now(tz=timezone.utc)
await session.commit()
await session.refresh(existing)
return existing
entry = catalog_model(**body.model_dump())
session.add(entry)
await session.commit()
await session.refresh(entry)
return entry
@router.post("/ingest/", response_model=TPSCSnapshotRead, status_code=status.HTTP_201_CREATED)
async def ingest_tpsc(
body: TPSCIngestRequest,
session: AsyncSession = Depends(get_session),
) -> TPSCSnapshotRead:
repo = (
await session.execute(select(repo_model).where(repo_model.slug == body.repo_slug))
).scalar_one_or_none()
repo_id = repo.id if repo else None
slugs = {entry.service_slug for entry in body.entries}
catalog_rows = []
if slugs:
catalog_rows = (
await session.execute(select(catalog_model).where(catalog_model.slug.in_(slugs)))
).scalars().all()
catalog_map = {row.slug: row for row in catalog_rows}
snapshot = snapshot_model(
repo_id=repo_id,
source_file=body.source_file,
entry_count=len(body.entries),
)
session.add(snapshot)
await session.flush()
entries_with_catalogs = []
for body_entry in body.entries:
catalog_entry = catalog_map.get(body_entry.service_slug)
entry = entry_model(
snapshot_id=snapshot.id,
catalog_id=catalog_entry.id if catalog_entry else None,
**body_entry.model_dump(),
)
session.add(entry)
entries_with_catalogs.append((entry, catalog_entry))
await session.flush()
await session.commit()
await session.refresh(snapshot)
return TPSCSnapshotRead(
id=snapshot.id,
repo_id=snapshot.repo_id,
snapshot_at=snapshot.snapshot_at,
source_file=snapshot.source_file,
entry_count=snapshot.entry_count,
entries=[
_entry_read(entry, catalog_entry)
for entry, catalog_entry in entries_with_catalogs
],
)
@router.get("/snapshots/", response_model=list[TPSCSnapshotRead])
async def list_snapshots(
repo_slug: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[TPSCSnapshotRead]:
q = select(snapshot_model).options(
selectinload(snapshot_model.entries).selectinload(entry_model.catalog_entry)
)
if repo_slug:
repo = (
await session.execute(select(repo_model).where(repo_model.slug == repo_slug))
).scalar_one_or_none()
if repo is None:
raise HTTPException(status_code=404, detail=f"Repo '{repo_slug}' not found")
q = q.where(snapshot_model.repo_id == repo.id)
q = q.order_by(snapshot_model.snapshot_at.desc())
rows = (await session.execute(q)).scalars().all()
return [_snapshot_read(row) for row in rows]
@router.get("/report/gdpr", response_model=TPSCGDPRReport)
async def gdpr_report(
session: AsyncSession = Depends(get_session),
) -> TPSCGDPRReport:
latest_sub = (
select(snapshot_model.repo_id, func.max(snapshot_model.snapshot_at).label("max_at"))
.group_by(snapshot_model.repo_id)
.subquery()
)
latest_snaps = (
await session.execute(
select(snapshot_model)
.join(
latest_sub,
(snapshot_model.repo_id == latest_sub.c.repo_id)
& (snapshot_model.snapshot_at == latest_sub.c.max_at),
)
.options(selectinload(snapshot_model.entries).selectinload(entry_model.catalog_entry))
)
).scalars().all()
all_repos = (await session.execute(select(repo_model))).scalars().all()
repo_map = {repo.id: repo.slug for repo in all_repos}
all_services = (await session.execute(select(catalog_model))).scalars().all()
by_maturity: dict[str, int] = {}
for service in all_services:
by_maturity[service.gdpr_maturity] = by_maturity.get(service.gdpr_maturity, 0) + 1
warnings = []
seen = set()
for snap in latest_snaps:
repo_slug = repo_map.get(snap.repo_id) if snap.repo_id else None
for entry in snap.entries:
catalog_entry = entry.catalog_entry
maturity = catalog_entry.gdpr_maturity if catalog_entry else "unknown"
if maturity not in GDPR_WARNING_LEVELS:
continue
key = (repo_slug, entry.service_slug)
if key in seen:
continue
seen.add(key)
warnings.append(
TPSCGDPRWarning(
repo_slug=repo_slug,
service_slug=entry.service_slug,
gdpr_maturity=maturity,
purpose=entry.purpose,
pricing_model=catalog_entry.pricing_model if catalog_entry else None,
)
)
return TPSCGDPRReport(
generated_at=datetime.now(tz=timezone.utc),
total_services=len(all_services),
warning_count=len(warnings),
warnings=warnings,
by_maturity=by_maturity,
)
return router
def _entry_read(entry: TPSCEntry, catalog_entry: TPSCCatalog | None) -> TPSCEntryRead:
return TPSCEntryRead(
id=entry.id,
snapshot_id=entry.snapshot_id,
catalog_id=entry.catalog_id,
service_slug=entry.service_slug,
purpose=entry.purpose,
auth_type=entry.auth_type,
endpoint_override=entry.endpoint_override,
notes=entry.notes,
gdpr_maturity=catalog_entry.gdpr_maturity if catalog_entry else None,
gdpr_warning=(catalog_entry.gdpr_maturity in GDPR_WARNING_LEVELS) if catalog_entry else True,
pricing_model=catalog_entry.pricing_model if catalog_entry else None,
)
def _snapshot_read(snapshot: TPSCSnapshot) -> TPSCSnapshotRead:
return TPSCSnapshotRead(
id=snapshot.id,
repo_id=snapshot.repo_id,
snapshot_at=snapshot.snapshot_at,
source_file=snapshot.source_file,
entry_count=snapshot.entry_count,
entries=[_entry_read(entry, entry.catalog_entry) for entry in snapshot.entries],
)

View File

@@ -0,0 +1,74 @@
from hub_core.schemas.agent_message import MessageCreate, MessageRead, MessageReply
from hub_core.schemas.capability import (
CapabilityRequestAccept,
CapabilityRequestCreate,
CapabilityRequestDispute,
CapabilityRequestPatch,
CapabilityRequestRead,
CapabilityRequestStatusPatch,
CatalogCreate,
CatalogPatch,
CatalogRead,
)
from hub_core.schemas.doi import DoICriterion, DoIReport, DoISummaryEntry
from hub_core.schemas.domain import DomainCreate, DomainDetail, DomainRead, DomainRename, DomainUpdate
from hub_core.schemas.managed_repo import RepoCreate, RepoPathRegister, RepoRead, RepoUpdate
from hub_core.schemas.policy import PolicyRead, PolicyUpdate
from hub_core.schemas.progress_event import ProgressEventCreate, ProgressEventRead
from hub_core.schemas.tpsc import (
AuthType,
GDPRMaturity,
GDPR_WARNING_LEVELS,
PricingModel,
TPSCCatalogCreate,
TPSCCatalogRead,
TPSCEntryCreate,
TPSCEntryRead,
TPSCGDPRReport,
TPSCGDPRWarning,
TPSCIngestRequest,
TPSCSnapshotRead,
)
__all__ = [
"CapabilityRequestAccept",
"CapabilityRequestCreate",
"CapabilityRequestDispute",
"CapabilityRequestPatch",
"CapabilityRequestRead",
"CapabilityRequestStatusPatch",
"CatalogCreate",
"CatalogPatch",
"CatalogRead",
"DoICriterion",
"DoIReport",
"DoISummaryEntry",
"DomainCreate",
"DomainDetail",
"DomainRead",
"DomainRename",
"DomainUpdate",
"AuthType",
"GDPRMaturity",
"GDPR_WARNING_LEVELS",
"MessageCreate",
"MessageRead",
"MessageReply",
"PolicyRead",
"PolicyUpdate",
"PricingModel",
"ProgressEventCreate",
"ProgressEventRead",
"RepoCreate",
"RepoPathRegister",
"RepoRead",
"RepoUpdate",
"TPSCCatalogCreate",
"TPSCCatalogRead",
"TPSCEntryCreate",
"TPSCEntryRead",
"TPSCGDPRReport",
"TPSCGDPRWarning",
"TPSCIngestRequest",
"TPSCSnapshotRead",
]

View File

@@ -0,0 +1,31 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class MessageCreate(BaseModel):
from_agent: str
to_agent: str
subject: str
body: str
thread_id: uuid.UUID | None = None
class MessageReply(BaseModel):
from_agent: str
body: str
class MessageRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
from_agent: str
to_agent: str
subject: str
body: str
thread_id: uuid.UUID | None = None
read_at: datetime | None = None
archived_at: datetime | None = None
created_at: datetime

View File

@@ -0,0 +1,99 @@
import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class CatalogCreate(BaseModel):
domain: str
capability_type: str
title: str
description: str | None = None
keywords: list[str] = Field(default_factory=list)
repo_slug: str | None = None
class CatalogPatch(BaseModel):
repo_slug: str | None = None
description: str | None = None
keywords: list[str] | None = None
status: str | None = None
class CatalogRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
domain_slug: str
repo_id: uuid.UUID | None = None
repo_slug: str | None = None
capability_type: str
title: str
description: str | None = None
keywords: list[str] = Field(default_factory=list)
status: str
created_at: datetime
updated_at: datetime
class CapabilityRequestCreate(BaseModel):
title: str
description: str | None = None
capability_type: str
priority: str = "medium"
requesting_domain: str
requesting_agent: str
request_context: dict[str, Any] | None = None
catalog_entry_id: uuid.UUID | None = None
class CapabilityRequestAccept(BaseModel):
fulfilling_agent: str
fulfillment_context: dict[str, Any] | None = None
class CapabilityRequestStatusPatch(BaseModel):
status: str
note: str | None = None
class CapabilityRequestPatch(BaseModel):
catalog_entry_id: uuid.UUID | None = None
priority: str | None = None
request_context: dict[str, Any] | None = None
fulfillment_context: dict[str, Any] | None = None
class CapabilityRequestDispute(BaseModel):
reason: str
disputed_by: str
suggested_domain: str | None = None
class CapabilityRequestRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
title: str
description: str | None = None
capability_type: str
priority: str
status: str
requesting_domain_slug: str
requesting_agent: str
request_context: dict[str, Any] | None = None
fulfilling_domain_slug: str | None = None
fulfilling_agent: str | None = None
fulfillment_context: dict[str, Any] | None = None
catalog_entry_id: uuid.UUID | None = None
resolution_note: str | None = None
routing_note: str | None = None
dispute_reason: str | None = None
disputed_by: str | None = None
dispute_suggested_domain: str | None = None
disputed_at: datetime | None = None
accepted_at: datetime | None = None
completed_at: datetime | None = None
created_at: datetime
updated_at: datetime

29
hub_core/schemas/doi.py Normal file
View File

@@ -0,0 +1,29 @@
from pydantic import BaseModel
class DoICriterion(BaseModel):
id: str
label: str
tier: str
status: str
detail: str = ""
class DoIReport(BaseModel):
repo_slug: str
tier: str
core_pass: bool
standard_pass: bool
full_pass: bool
criteria: list[DoICriterion] = []
checked_at: str
class DoISummaryEntry(BaseModel):
repo_slug: str
domain_slug: str | None
tier: str
core_pass: bool
standard_pass: bool
full_pass: bool
checked_at: str

View File

@@ -0,0 +1,49 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class DomainCreate(BaseModel):
slug: str
name: str
description: str | None = None
class DomainUpdate(BaseModel):
name: str | None = None
description: str | None = None
status: str | None = None
class DomainRename(BaseModel):
new_slug: str
new_name: str
class RepoStub(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
slug: str
name: str
local_path: str | None = None
remote_url: str | None = None
status: str
class DomainRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
slug: str
name: str
description: str | None = None
status: str
created_at: datetime
updated_at: datetime
class DomainDetail(DomainRead):
repos: list[RepoStub] = Field(default_factory=list)
extension_counts: dict[str, int] = Field(default_factory=dict)

View File

@@ -0,0 +1,48 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class RepoCreate(BaseModel):
domain_slug: str
slug: str
name: str
local_path: str | None = None
host_paths: dict = Field(default_factory=dict)
remote_url: str | None = None
git_fingerprint: str | None = None
description: str | None = None
class RepoUpdate(BaseModel):
name: str | None = None
local_path: str | None = None
host_paths: dict | None = None
remote_url: str | None = None
git_fingerprint: str | None = None
description: str | None = None
status: str | None = None
class RepoPathRegister(BaseModel):
host: str
path: str
class RepoRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
domain_id: uuid.UUID
domain_slug: str
slug: str
name: str
local_path: str | None = None
host_paths: dict = Field(default_factory=dict)
remote_url: str | None = None
git_fingerprint: str | None = None
description: str | None = None
status: str
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,10 @@
from pydantic import BaseModel
class PolicyRead(BaseModel):
name: str
content: str
class PolicyUpdate(BaseModel):
content: str

View File

@@ -0,0 +1,27 @@
import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict
class ProgressEventCreate(BaseModel):
event_type: str
summary: str
detail: dict[str, Any] | None = None
subject_refs: dict[str, Any] | None = None
author: str | None = None
session_id: str | None = None
class ProgressEventRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
event_type: str
summary: str
detail: dict[str, Any] | None = None
subject_refs: dict[str, Any] | None = None
author: str | None = None
session_id: str | None = None
created_at: datetime

116
hub_core/schemas/tpsc.py Normal file
View File

@@ -0,0 +1,116 @@
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, computed_field
GDPRMaturity = Literal[
"unknown",
"non_compliant",
"initial",
"developing",
"defined",
"managed",
"certified",
]
GDPR_WARNING_LEVELS = {"unknown", "non_compliant", "initial"}
PricingModel = Literal["free", "paid", "freemium", "usage_based", "unknown"]
AuthType = Literal["api_key", "oauth", "cli", "none", "unknown"]
class TPSCCatalogCreate(BaseModel):
slug: str
name: str
provider: str | None = None
category: str | None = None
website_url: str | None = None
pricing_model: PricingModel = "unknown"
gdpr_maturity: GDPRMaturity = "unknown"
gdpr_notes: str | None = None
dpa_available: bool = False
tos_url: str | None = None
privacy_policy_url: str | None = None
data_processing_regions: list[str] | None = None
data_retention_notes: str | None = None
status: str = "active"
class TPSCCatalogRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
slug: str
name: str
provider: str | None
category: str | None
website_url: str | None
pricing_model: str
gdpr_maturity: str
gdpr_notes: str | None
dpa_available: bool
tos_url: str | None
privacy_policy_url: str | None
data_processing_regions: list[str] | None
data_retention_notes: str | None
status: str
created_at: datetime
updated_at: datetime
@computed_field
@property
def gdpr_warning(self) -> bool:
return self.gdpr_maturity in GDPR_WARNING_LEVELS
class TPSCEntryCreate(BaseModel):
service_slug: str
purpose: str | None = None
auth_type: str | None = None
endpoint_override: str | None = None
notes: str | None = None
class TPSCEntryRead(TPSCEntryCreate):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
snapshot_id: uuid.UUID
catalog_id: uuid.UUID | None = None
gdpr_maturity: str | None = None
gdpr_warning: bool = False
pricing_model: str | None = None
class TPSCIngestRequest(BaseModel):
repo_slug: str
source_file: str = "tpsc.yaml"
entries: list[TPSCEntryCreate]
class TPSCSnapshotRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
repo_id: uuid.UUID | None = None
snapshot_at: datetime
source_file: str | None = None
entry_count: int
entries: list[TPSCEntryRead] = Field(default_factory=list)
class TPSCGDPRWarning(BaseModel):
repo_slug: str | None
service_slug: str
gdpr_maturity: str
purpose: str | None
pricing_model: str | None
class TPSCGDPRReport(BaseModel):
generated_at: datetime
total_services: int
warning_count: int
warnings: list[TPSCGDPRWarning]
by_maturity: dict[str, int]

View File

@@ -0,0 +1,12 @@
from hub_core.utils.pagination import PageParams, apply_pagination
from hub_core.utils.paths import resolve_repo_path
from hub_core.utils.routing import normalize_trailing_slash
from hub_core.utils.slugs import slugify
__all__ = [
"PageParams",
"apply_pagination",
"normalize_trailing_slash",
"resolve_repo_path",
"slugify",
]

View File

@@ -0,0 +1,22 @@
from dataclasses import dataclass
from typing import TypeVar
from sqlalchemy.sql import Select
SelectT = TypeVar("SelectT", bound=Select)
@dataclass(frozen=True)
class PageParams:
limit: int = 100
offset: int = 0
def __post_init__(self) -> None:
if self.limit < 1 or self.limit > 1000:
raise ValueError("limit must be between 1 and 1000")
if self.offset < 0:
raise ValueError("offset must be >= 0")
def apply_pagination(query: SelectT, page: PageParams) -> SelectT:
return query.offset(page.offset).limit(page.limit)

13
hub_core/utils/paths.py Normal file
View File

@@ -0,0 +1,13 @@
import socket
from typing import Protocol
class RepoPathLike(Protocol):
local_path: str | None
host_paths: dict
def resolve_repo_path(repo: RepoPathLike, host: str | None = None) -> str | None:
selected_host = host or socket.gethostname()
host_paths = repo.host_paths or {}
return host_paths.get(selected_host) or repo.local_path

27
hub_core/utils/routing.py Normal file
View File

@@ -0,0 +1,27 @@
from urllib.parse import SplitResult, urlsplit, urlunsplit
def normalize_trailing_slash(path_or_url: str, *, trailing: bool = True) -> str:
"""Normalize the path component while preserving query and fragment."""
if not path_or_url:
return "/" if trailing else ""
parts = urlsplit(path_or_url)
path = parts.path or "/"
if trailing:
normalized_path = path if path.endswith("/") else f"{path}/"
elif path == "/":
normalized_path = "/"
else:
normalized_path = path.rstrip("/")
if parts.scheme or parts.netloc:
return urlunsplit(
SplitResult(parts.scheme, parts.netloc, normalized_path, parts.query, parts.fragment)
)
suffix = ""
if parts.query:
suffix += f"?{parts.query}"
if parts.fragment:
suffix += f"#{parts.fragment}"
return f"{normalized_path}{suffix}"

14
hub_core/utils/slugs.py Normal file
View File

@@ -0,0 +1,14 @@
import re
_NON_SLUG = re.compile(r"[^a-z0-9]+")
_DASHES = re.compile(r"-+")
def slugify(value: str, *, max_length: int = 100) -> str:
slug = _NON_SLUG.sub("-", value.strip().lower())
slug = _DASHES.sub("-", slug).strip("-")
if not slug:
raise ValueError("slug cannot be empty")
if max_length < 1:
raise ValueError("max_length must be >= 1")
return slug[:max_length].strip("-")