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

27
.custodian-brief.md Normal file
View File

@@ -0,0 +1,27 @@
<!-- custodian-brief: generated by statehub register; fix-consistency may replace this file -->
# Custodian Brief - hub-core
**Project:** hub-core
**Domain:** inter_hub
**State Hub:** http://127.0.0.1:8000
**Topic ID:** `1f2e4d10-c967-4803-ae6c-7f4b4e806409`
## Open Workplans
### Bootstrap State Hub integration
Workplan file: `workplans/HUB-WP-0001-statehub-bootstrap.md`
Open tasks:
- T01 - Review generated integration files
- T02 - Verify local developer workflow
- T03 - Seed first real workplan
## Session Start
1. Read `INTENT.md`, `SCOPE.md`, and `AGENTS.md`.
2. Check inbox: `GET /messages/?to_agent=hub-core&unread_only=true`.
3. Scan `workplans/`.
4. Update task statuses in workplan files as work progresses.
Last generated: 2026-06-16

162
AGENTS.md Normal file
View File

@@ -0,0 +1,162 @@
# hub-core — Agent Instructions
## Repo Identity
**Purpose:** **Updated:** 2026-06-16.
**Domain:** inter_hub
**Repo slug:** hub-core
**Topic ID:** `1f2e4d10-c967-4803-ae6c-7f4b4e806409`
**Workplan prefix:** `HUB-WP-`
---
## State Hub Integration
The Custodian State Hub tracks work across all domains. Interact via HTTP REST —
there is no MCP server for Codex agents.
| Context | URL |
|---------|-----|
| Local workstation | `http://127.0.0.1:8000` |
| Remote via tunnel | `http://127.0.0.1:18000` |
### Orient at session start
```bash
# Offline brief — works without hub connection
cat .custodian-brief.md
# Active workstreams for this domain
curl -s "http://127.0.0.1:8000/workstreams/?topic_id=1f2e4d10-c967-4803-ae6c-7f4b4e806409&status=active" \
| python3 -m json.tool
# Check inbox
curl -s "http://127.0.0.1:8000/messages/?to_agent=hub-core&unread_only=true" \
| python3 -m json.tool
```
Mark a message read:
```bash
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
-H "Content-Type: application/json" -d '{}'
```
### Log progress (required at session close)
```bash
curl -s -X POST http://127.0.0.1:8000/progress/ \
-H "Content-Type: application/json" \
-d '{
"summary": "what was done",
"event_type": "note",
"author": "codex",
"workstream_id": "<uuid>",
"task_id": "<uuid>"
}'
```
Omit `workstream_id` / `task_id` when not applicable.
### Update task status
```bash
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
-H "Content-Type: application/json" \
-d '{"status": "progress"}'
# values: wait | todo | progress | done | cancel
```
### Flag a task for human review
```bash
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
-H "Content-Type: application/json" \
-d '{"needs_human": true, "intervention_note": "reason"}'
```
---
## Session Protocol
**Start:**
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
2. Check inbox: `GET /messages/?to_agent=hub-core&unread_only=true`; mark read
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
4. Check human-needed tasks: `GET /tasks/?needs_human=true`
**During work:**
- Update task statuses in workplan files as tasks progress
- Record significant decisions via `POST /decisions/`
**Close:**
1. Update workplan file task statuses to reflect progress
2. Log: `POST /progress/` with a summary of what changed
3. Note for the custodian operator: after workplan file changes, run from
`~/state-hub`:
```bash
make fix-consistency REPO=hub-core
```
This syncs task status from files into the hub DB.
---
## Workplan Convention (ADR-001)
Work items originate as files in this repo — not in the hub. The hub is a
read/cache/index layer that rebuilds from files.
**File location:** `workplans/HUB-WP-NNNN-<slug>.md`
**Archived location:** finished workplans may move to
`workplans/archived/YYMMDD-HUB-WP-NNNN-<slug>.md`. The `YYMMDD` prefix is
the completion/archive date; the frontmatter `id` does not change.
**Ad Hoc Tasks:** small opportunistic fixes discovered during a session use
`workplans/ADHOC-YYYY-MM-DD.md` with task ids `ADHOC-YYYY-MM-DD-T01`, etc. Use
this only for low-risk work completed directly; create a normal workplan for
anything needing analysis, design, approval, dependencies, or multiple phases.
**Frontmatter:**
```yaml
---
id: HUB-WP-NNNN
type: workplan
title: "..."
domain: inter_hub
repo: hub-core
status: proposed | ready | active | blocked | backlog | finished | archived
owner: codex
topic_slug: ...
created: "YYYY-MM-DD"
updated: "YYYY-MM-DD"
state_hub_workstream_id: "<uuid>" # written by fix-consistency — do not edit
---
```
Use `proposed` for a new draft, `ready` after review against current repo
state, and `finished` after implementation. `stalled` and `needs_review` are
derived health labels, not frontmatter statuses.
**Task block format** (one per `##` section):
```
## Task Title
` ` `task
id: HUB-WP-NNNN-T01
status: wait | todo | progress | done | cancel
priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
` ` `
Task description text.
```
Status progression: `todo` → `progress` → `done`; use `wait` for waiting/blocked work and `cancel` for stopped work.
To create a new workplan:
1. Write the file following the format above
2. Notify the custodian operator to run `make fix-consistency REPO=hub-core`
(or send a message to the hub agent via `POST /messages/`)

137
INTENT.md Normal file
View File

@@ -0,0 +1,137 @@
# INTENT — hub-core
**Project:** `hub-core`
**Domain:** `inter_hub`
**Status:** Active extraction (CUST-WP-0025)
**Updated:** 2026-06-16
---
## One-line intent
`hub-core` provides reusable FastAPI, SQLAlchemy, and MCP primitives so multiple
FOS hub services can share a common foundation without importing each other's
domain-specific coordination models.
---
## Why it exists
Custodian and helix_forge ecosystems need more than one hub-shaped service:
development coordination (`state-hub`), operations (`ops-hub`), finance, and
future domain hubs. Those services repeat the same patterns — domain registry,
managed repositories, agent messaging, progress telemetry, capability catalog
surfaces, third-party service catalog (TPSC), policy lookup, and MCP
orientation tools.
Without `hub-core`, each hub would duplicate SQLAlchemy models, Pydantic
contracts, router mounting, pagination helpers, and MCP wrappers. That leads to
schema drift, incompatible agent tools, and expensive extractions every time a
second hub appears.
`hub-core` exists to extract the **generic hub substrate** once and let each hub
package own only its domain-specific tables, workflows, and policies.
---
## Governing principle
> **Hub-core is a library, not a hub.**
It ships models, schemas, router factories, migration scaffolds, utilities, and
an optional FastMCP base server. A consuming repository (for example
`state-hub`) wires database sessions, auth, host-specific callbacks, and
domain-only routes into those factories.
`hub-core` should answer:
1. **What primitives do all FOS hubs share?** Domains, repos, messages,
progress events, capability catalog/request read paths, TPSC catalog/snapshots,
policy lookup, canonical risk/alert event types.
2. **How do hubs expose them consistently?** Factory-based FastAPI routers and
matching MCP tools with dependency injection at the host boundary.
3. **How do hubs evolve schema together?** Shared Alembic templates and a
documented core-schema migration adopters can extend.
It should **not** answer dev-hub questions such as which workstream is blocked,
which task needs human review, or how kaizen agents spawn maintenance work. Those
remain in `state-hub` and other host implementations.
---
## What it is
`hub-core` is the **shared Python package** for FOS hub services.
Current package surface (`hub_core/`):
| Area | Responsibility |
|---|---|
| `models/` | SQLAlchemy base, domains, managed repos, agent messages, capability catalog/requests, progress events, TPSC |
| `schemas/` | Pydantic contracts matching core models plus DoI report shapes |
| `routers/` | Factory functions: domains, repos, messages, progress, capabilities, TPSC, policy |
| `mcp/` | `HubCoreMCPServer` — generic orientation, messaging, capability, repo, DoI, TPSC/GDPR, risk/alert, progress tools |
| `migrations/` | Alembic scaffold and `0001_core_schema` for adopters |
| `utils/` | Slugs, pagination, repo path resolution, trailing-slash routing |
| `events.py` | Canonical FOS §10 risk and alert event types |
Hosts mount only the routers they need and inject their own `Session` providers,
models where extended, and workflow callbacks.
---
## What it is not
| Concern | Owner |
|---|---|
| Running production hub deployment | `state-hub`, `ops-hub`, future hubs |
| Topics, workstreams, tasks, decisions, SBOM, token accounting | `state-hub` (dev-hub layer) |
| Custodian canon, constitution, domain charters | `the-custodian` |
| Event-triggered maintenance task creation | `activity-core` |
| General issue/task lifecycle outside Custodian workplans | `issue-core` |
| Capability reuse registry and federation compose | `reuse-surface` |
| Network tunnels and remote operations | `ops-bridge` |
`hub-core` may define generic capability **catalog** and **request read**
primitives, but workflow side effects (task unblocking, dispute resolution,
acceptance flows) stay in the host hub.
---
## Primary consumers
| Consumer | Relationship |
|---|---|
| `state-hub` | First adopter; incremental import of schemas, routers, MCP (CUST-WP-0025 T08+) |
| `ops-hub` | Planned consumer of shared primitives without dev-hub tables |
| Future FOS hubs | Fin-hub and domain hubs mount subsets of hub-core factories |
Extraction boundary and migration status:
`/home/worsch/the-custodian/docs/hub-core-extraction-boundary.md`
---
## Success criteria
`hub-core` succeeds when:
- a new hub can register domains and repos using hub-core routers without copying SQLAlchemy models
- State Hub pytest suite passes with hub-core as an editable dependency
- MCP tools for orientation, messages, progress, and TPSC behave consistently across hosts that opt in
- schema changes to shared primitives are versioned through hub-core migrations, not ad hoc forks
- dev-hub-specific foreign keys never appear in hub-core models (extension via host callbacks or JSON context fields)
---
## Non-goals
- Replacing FastAPI, SQLAlchemy, or FastMCP
- Owning PostgreSQL instance provisioning for any environment
- Becoming a general application framework unrelated to hub-shaped services
- Absorbing reuse-surface capability maturity registry semantics
---
## Working mantra
> Extract once what every hub needs; keep domain drama in the hub that owns it.

View File

@@ -1,3 +1,36 @@
# repo-seed
# Hub Core
A git repository template to bootstrap coulomb projects from.
Reusable FastAPI, SQLAlchemy, and MCP primitives for FOS hubs.
`hub-core` is being extracted from the standalone State Hub repository as part
of `CUST-WP-0025`. The initial package slice contains only the generic database
models and schemas that can move without importing dev-hub concepts such as
topics, workstreams, tasks, decisions, SBOM, or token accounting.
Source boundary notes live in:
```text
/home/worsch/the-custodian/docs/hub-core-extraction-boundary.md
```
## First Slice
- SQLAlchemy base metadata and timestamp helpers.
- Domain and managed-repository registry primitives.
- Agent message inbox primitives.
- Progress-event and capability-request primitives with generic JSON context
fields for hub-specific references.
- Third-party service catalog and snapshot primitives.
- Matching Pydantic schemas for those primitives.
- Generic DoI report and summary schemas used by the MCP DoI tools.
- Router factory functions for domains, repos, messages, policy lookup, and
progress, capability, and TPSC catalog/snapshot/report endpoints.
- Canonical FOS §10 risk and alert event types with `/progress/risks` and
`/progress/alerts` read views.
- Shared utility helpers for slugs, pagination, repo path resolution, and
trailing-slash path normalization.
- Alembic templates plus an initial core-schema migration for hub adopters.
- FastMCP base-server wrapper for generic orientation, messaging, capability,
repo, DoI, TPSC/GDPR, risk/alert, and progress tools.
Domain-specific MCP tools follow in each hub package.

152
SCOPE.md Normal file
View File

@@ -0,0 +1,152 @@
# SCOPE — hub-core
**Updated:** 2026-06-16
---
## One-liner
Reusable Python package of FastAPI router factories, SQLAlchemy models, Pydantic
schemas, MCP tooling, and migration scaffolds for FOS hub services.
---
## Core idea
`hub-core` is a **library boundary** between shared hub infrastructure and
host-specific hub implementations. Host repositories depend on `hub-core` as an
editable or published package; they run the actual HTTP/MCP service, own
deployment, and add domain tables and workflows on top.
---
## In scope
- **`hub_core` Python package** — models, schemas, routers, MCP server wrapper,
utilities, events, database helpers
- **Router factories** with host-injected sessions, models, and callbacks
(domains, repos, messages, progress, capabilities, TPSC, policy)
- **Alembic migration scaffold** for core tables adopters extend
- **Tests** under `tests/` proving import seams and MCP behavior
- **Package metadata** — `pyproject.toml`, hatchling wheel build
- **Capability registry scaffold** — `registry/` per helix_forge federation
contract (entries added when reusable behaviors are registered)
- **Documentation** — `README.md`, `INTENT.md`, `SCOPE.md`, pointer to
extraction boundary in `the-custodian`
---
## Out of scope
- Long-running hub service, Docker image, or production URL for hub-core itself
- Dev-hub tables: topics, workstreams, tasks, decisions, dependencies, SBOM,
token accounting, kaizen agents
- State Hub dashboard UI, consistency sync scripts, and workplan file authority
- Custodian canon content and constitution maintenance
- Plaintext secrets, environment-specific connection strings committed to git
- Replacing or wrapping non-hub application domains (feature-control, reuse-surface, etc.)
---
## What is possible now
After the CUST-WP-0025 first slice (2026-06-06 — 2026-06-07):
| Capability | Status |
|---|---|
| Install as editable package | `pip install -e .` / uv equivalent |
| Import core models and schemas | `hub_core.models`, `hub_core.schemas` |
| Mount generic routers in a host FastAPI app | `hub_core.routers.create_*_router` |
| Run generic MCP tools via `HubCoreMCPServer` | `hub_core.mcp` |
| Apply core-schema migration template | `hub_core/migrations/versions/0001_core_schema.py` |
| Adopt shared slug/pagination/path utilities | `hub_core.utils` |
| Expose risk/alert progress read views | `/progress/risks`, `/progress/alerts` patterns |
| State Hub incremental adoption | Schemas, messages, policy, TPSC, progress, domains, capability catalog routers imported |
```bash
cd ~/hub-core
python3 -m venv .venv && .venv/bin/pip install -e .
.venv/bin/pytest -q
```
---
## What is not possible yet
- **Published PyPI package** — consumed via editable path or private index only
- **Standalone `hub-core serve`** — no CLI entrypoint; hosts own `uvicorn`
- **Complete State Hub decoupling** — dev-hub routes and models still live in `state-hub`
- **ops-hub / fin-hub adoption** — planned; not verified in this repo
- **Capability registry entries** — scaffold only (`capabilities: []`); no registered reusable behaviors yet
- **Gitea federation publish** — repo not yet on Gitea; blocks T01 in reuse-surface WP-0015
---
## Current state
| Item | Value |
|---|---|
| Package version | `0.1.0` (`hub_core.__version__`) |
| Python | `>=3.12` |
| Dependencies | FastAPI, FastMCP, SQLAlchemy, Pydantic, httpx |
| Tests | pytest under `tests/` |
| Registry | Empty capability index; federation scaffold present |
| Primary consumer | `state-hub` (editable dependency, router/schema import in progress) |
| Extraction workplan | `CUST-WP-0025` (custodian domain) |
---
## Repository layout
```text
hub-core/
├── INTENT.md
├── SCOPE.md
├── README.md
├── pyproject.toml
├── hub_core/
│ ├── models/
│ ├── schemas/
│ ├── routers/
│ ├── mcp/
│ ├── migrations/
│ ├── utils/
│ ├── database.py
│ └── events.py
├── registry/
│ ├── capabilities/
│ └── indexes/capabilities.yaml
└── tests/
```
---
## Boundaries with sibling repos
| Repo | Boundary |
|---|---|
| `state-hub` | Host dev-hub; imports hub-core factories; keeps workstream/task/decision logic |
| `the-custodian` | Owns extraction boundary doc and CUST-WP-0025 workplan |
| `reuse-surface` | Federation hub for capability indexes; not a runtime dependency of hub-core |
| `ops-hub` | Future consumer; operations-specific tables stay local |
---
## Workplan convention
Hub-core extraction and package work is tracked under **custodian** workplans
(for example `CUST-WP-0025`). Host adoption milestones are tracked in
`state-hub` workplans (for example `CUST-WP-0048`).
When hub-core gains repo-local workplans, prefer a stable prefix agreed with
custodian operators (for example `HUBCORE-WP-####`).
---
## Getting oriented
- Product intent: `INTENT.md`
- Extraction boundary: `/home/worsch/the-custodian/docs/hub-core-extraction-boundary.md`
- Package entry: `hub_core/__init__.py`, `hub_core/routers/__init__.py`
- Consumer example: `/home/worsch/state-hub` (editable `hub-core` dependency)
- Federation registry: `registry/README.md` (reuse-surface contract)

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

23
pyproject.toml Normal file
View File

@@ -0,0 +1,23 @@
[project]
name = "hub-core"
version = "0.1.0"
description = "Reusable core primitives for FOS hubs"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115.0",
"fastmcp>=2.0.0",
"httpx>=0.28.0",
"sqlalchemy[asyncio]>=2.0.0",
"pydantic>=2.10.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["hub_core"]
artifacts = ["hub_core/migrations/script.py.mako"]
[tool.pytest.ini_options]
testpaths = ["tests"]

View File

@@ -1,4 +1,4 @@
version: 1
updated: '2026-06-16'
domain: helix_forge
domain: inter_hub
capabilities: []

391
tests/test_imports.py Normal file
View File

@@ -0,0 +1,391 @@
import uuid
from datetime import datetime, timezone
from hub_core.events import ALERT_EVENT_TYPES, FOS10_EVENT_TYPES, RISK_EVENT_TYPES
from hub_core.models import Base
from hub_core.models.agent_message import AgentMessage
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
from hub_core.routers import (
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,
)
from hub_core.schemas.capability import (
CapabilityRequestRead,
CatalogCreate,
CatalogPatch,
CatalogRead,
)
from hub_core.schemas.domain import (
DomainCreate,
DomainDetail,
DomainRead,
DomainRename,
DomainUpdate,
RepoStub,
)
from hub_core.schemas.doi import DoICriterion, DoIReport, DoISummaryEntry
from hub_core.schemas.managed_repo import (
RepoCreate,
RepoPathRegister,
RepoRead,
RepoUpdate,
)
from hub_core.schemas.policy import PolicyRead
from hub_core.schemas.progress_event import ProgressEventCreate, ProgressEventRead
from hub_core.schemas.tpsc import TPSCCatalogRead, TPSCGDPRReport, TPSCGDPRWarning
def test_core_tables_are_registered() -> None:
assert set(Base.metadata.tables) == {
"agent_messages",
"capability_catalog",
"capability_requests",
"domains",
"managed_repos",
"progress_events",
"tpsc_catalog",
"tpsc_entries",
"tpsc_snapshots",
}
def test_model_table_names() -> None:
assert AgentMessage.__tablename__ == "agent_messages"
assert CapabilityCatalog.__tablename__ == "capability_catalog"
assert CapabilityRequest.__tablename__ == "capability_requests"
assert Domain.__tablename__ == "domains"
assert ManagedRepo.__tablename__ == "managed_repos"
assert ProgressEvent.__tablename__ == "progress_events"
assert TPSCCatalog.__tablename__ == "tpsc_catalog"
assert TPSCEntry.__tablename__ == "tpsc_entries"
assert TPSCSnapshot.__tablename__ == "tpsc_snapshots"
def test_doi_schemas_are_available() -> None:
criterion = DoICriterion(id="C1", label="Canonical files", tier="core", status="pass")
report = DoIReport(
repo_slug="example",
tier="core",
core_pass=True,
standard_pass=False,
full_pass=False,
criteria=[criterion],
checked_at="2026-06-07T00:00:00+00:00",
)
summary = DoISummaryEntry(
repo_slug="example",
domain_slug="custodian",
tier="core",
core_pass=True,
standard_pass=False,
full_pass=False,
checked_at=report.checked_at,
)
assert report.criteria[0].id == "C1"
assert summary.domain_slug == "custodian"
def test_tpsc_schemas_match_state_hub_contract() -> None:
now = datetime.now(tz=timezone.utc)
catalog_entry = TPSCCatalogRead(
id=uuid.uuid4(),
slug="example-service",
name="Example Service",
provider="Example",
category="ops",
website_url=None,
pricing_model="paid",
gdpr_maturity="unknown",
gdpr_notes=None,
dpa_available=False,
tos_url=None,
privacy_policy_url=None,
data_processing_regions=None,
data_retention_notes=None,
status="active",
created_at=now,
updated_at=now,
)
warning = TPSCGDPRWarning(
repo_slug="state-hub",
service_slug="example-service",
gdpr_maturity="unknown",
purpose="testing",
pricing_model="paid",
)
report = TPSCGDPRReport(
generated_at=now,
total_services=1,
warning_count=1,
warnings=[warning],
by_maturity={"unknown": 1},
)
assert catalog_entry.gdpr_warning is True
assert report.warning_count == len(report.warnings)
assert report.by_maturity["unknown"] == 1
def test_router_factories_register_expected_prefixes() -> None:
async def get_session():
raise AssertionError("router construction should not resolve sessions")
routers = [
create_capabilities_router(get_session),
create_domains_router(get_session),
create_messages_router(get_session),
create_repos_router(get_session),
create_progress_router(get_session),
create_tpsc_router(get_session),
create_policy_router(lambda name: None),
]
assert [router.prefix for router in routers] == [
"",
"/domains",
"/messages",
"/repos",
"/progress",
"/tpsc",
"/policy",
]
assert all(router.routes for router in routers)
def test_messages_router_accepts_host_model_injection() -> None:
async def get_session():
raise AssertionError("router construction should not resolve sessions")
router = create_messages_router(get_session, message_model=AgentMessage)
assert router.prefix == "/messages"
assert router.routes
def test_domains_router_accepts_host_callbacks_and_schema_injection() -> None:
async def get_session():
raise AssertionError("router construction should not resolve sessions")
async def detail_builder(domain, session):
raise AssertionError("router construction should not build details")
async def before_archive(domain, session):
raise AssertionError("router construction should not validate archive")
router = create_domains_router(
get_session,
domain_model=Domain,
repo_model=ManagedRepo,
domain_create_schema=DomainCreate,
domain_detail_schema=DomainDetail,
domain_read_schema=DomainRead,
domain_rename_schema=DomainRename,
domain_update_schema=DomainUpdate,
repo_stub_schema=RepoStub,
detail_builder=detail_builder,
before_archive=before_archive,
include_update_route=False,
)
method_paths = {
(method, route.path)
for route in router.routes
for method in getattr(route, "methods", set())
}
assert router.prefix == "/domains"
assert ("PATCH", "/domains/{slug}") not in method_paths
assert ("PATCH", "/domains/{slug}/rename") in method_paths
assert ("PATCH", "/domains/{slug}/archive") in method_paths
def test_repos_router_accepts_host_model_and_schema_injection() -> None:
async def get_session():
raise AssertionError("router construction should not resolve sessions")
def after_register(repo, body, domain):
raise AssertionError("router construction should not call hooks")
router = create_repos_router(
get_session,
prefix="",
domain_model=Domain,
repo_model=ManagedRepo,
repo_create_schema=RepoCreate,
repo_update_schema=RepoUpdate,
repo_read_schema=RepoRead,
repo_path_register_schema=RepoPathRegister,
list_noload_fields=("domain",),
create_extension_fields=("topic_id",),
after_register=after_register,
include_slug_routes=False,
)
method_paths = {
(method, route.path)
for route in router.routes
for method in getattr(route, "methods", set())
}
assert router.prefix == ""
assert method_paths == {
("GET", "/"),
("POST", "/"),
("GET", "/by-fingerprint"),
("GET", "/by-remote"),
}
def test_repos_router_can_register_only_slug_routes() -> None:
async def get_session():
raise AssertionError("router construction should not resolve sessions")
router = create_repos_router(
get_session,
prefix="",
include_collection_routes=False,
include_lookup_routes=False,
)
method_paths = {
(method, route.path)
for route in router.routes
for method in getattr(route, "methods", set())
}
assert method_paths == {
("GET", "/{slug}"),
("PATCH", "/{slug}"),
("POST", "/{slug}/paths"),
}
def test_capability_catalog_router_accepts_host_model_injection() -> None:
async def get_session():
raise AssertionError("router construction should not resolve sessions")
router = create_capability_catalog_router(
get_session,
domain_model=Domain,
repo_model=ManagedRepo,
catalog_model=CapabilityCatalog,
catalog_create_schema=CatalogCreate,
catalog_patch_schema=CatalogPatch,
catalog_read_schema=CatalogRead,
)
method_paths = {
(method, route.path)
for route in router.routes
for method in getattr(route, "methods", set())
}
assert router.prefix == ""
assert method_paths == {
("GET", "/capability-catalog/"),
("POST", "/capability-catalog/"),
("PATCH", "/capability-catalog/{entry_id}"),
}
def test_capability_request_read_router_accepts_host_model_injection() -> None:
async def get_session():
raise AssertionError("router construction should not resolve sessions")
router = create_capability_request_read_router(
get_session,
domain_model=Domain,
request_model=CapabilityRequest,
request_read_schema=CapabilityRequestRead,
)
method_paths = {
(method, route.path)
for route in router.routes
for method in getattr(route, "methods", set())
}
assert router.prefix == ""
assert method_paths == {
("GET", "/capability-requests/"),
("GET", "/capability-requests/{request_id}"),
}
def test_tpsc_router_accepts_host_model_injection() -> None:
async def get_session():
raise AssertionError("router construction should not resolve sessions")
router = create_tpsc_router(
get_session,
repo_model=ManagedRepo,
catalog_model=TPSCCatalog,
snapshot_model=TPSCSnapshot,
entry_model=TPSCEntry,
)
assert router.prefix == "/tpsc"
assert router.routes
def test_progress_router_accepts_host_model_and_schema_injection() -> None:
async def get_session():
raise AssertionError("router construction should not resolve sessions")
router = create_progress_router(
get_session,
progress_model=ProgressEvent,
progress_create_schema=ProgressEventCreate,
progress_read_schema=ProgressEventRead,
)
assert router.prefix == "/progress"
assert router.routes
def test_policy_router_can_register_update_route() -> None:
router = create_policy_router(
lambda name: None,
update_policy=lambda name, content: PolicyRead(name=name, content=content),
)
method_paths = {
(method, route.path)
for route in router.routes
for method in getattr(route, "methods", set())
}
assert ("GET", "/policy/{name}") in method_paths
assert ("PUT", "/policy/{name}") in method_paths
def test_fos10_event_contract() -> None:
assert RISK_EVENT_TYPES == {
"risk_surfaced",
"risk_mitigated",
"risk_escalated",
}
assert ALERT_EVENT_TYPES == {
"alert_raised",
"alert_acknowledged",
"alert_resolved",
}
assert FOS10_EVENT_TYPES == RISK_EVENT_TYPES | ALERT_EVENT_TYPES
def test_progress_router_registers_fos10_views() -> None:
async def get_session():
raise AssertionError("router construction should not resolve sessions")
router = create_progress_router(get_session)
paths = {route.path for route in router.routes}
assert "/progress/risks" in paths
assert "/progress/alerts" in paths

31
tests/test_mcp.py Normal file
View File

@@ -0,0 +1,31 @@
import asyncio
from hub_core.mcp import HubCoreMCPServer
def test_mcp_base_server_constructs_without_registering_tools() -> None:
server = HubCoreMCPServer(
name="test-hub",
api_base="http://127.0.0.1:9999/",
register_tools=False,
)
assert server.api_base == "http://127.0.0.1:9999"
assert server.mcp.name == "test-hub"
assert server._clean({"a": None, "b": 1}) == {"b": 1}
def test_mcp_base_server_registers_orientation_doi_and_fos10_tools() -> None:
server = HubCoreMCPServer(name="test-hub", api_base="http://127.0.0.1:9999")
tools = asyncio.run(server.mcp.list_tools())
names = {tool.name for tool in tools}
assert {
"get_state_summary",
"get_domain_summary",
"check_repo_doi",
"get_doi_summary",
"get_risks",
"get_alerts",
} <= names

45
tests/test_utils.py Normal file
View File

@@ -0,0 +1,45 @@
import pytest
from sqlalchemy import select
from hub_core.models.domain import Domain
from hub_core.utils import PageParams, apply_pagination, normalize_trailing_slash, resolve_repo_path, slugify
class RepoStub:
local_path = "/fallback/path"
host_paths = {"workstation": "/host/path"}
def test_slugify_normalizes_text() -> None:
assert slugify(" The Custodian: Hub Core! ") == "the-custodian-hub-core"
def test_slugify_rejects_empty_slug() -> None:
with pytest.raises(ValueError, match="slug cannot be empty"):
slugify(" !!! ")
def test_page_params_bounds() -> None:
assert PageParams(limit=10, offset=20).limit == 10
with pytest.raises(ValueError, match="limit"):
PageParams(limit=0)
with pytest.raises(ValueError, match="offset"):
PageParams(offset=-1)
def test_apply_pagination_sets_limit_and_offset() -> None:
query = apply_pagination(select(Domain), PageParams(limit=25, offset=50))
compiled = str(query.compile(compile_kwargs={"literal_binds": True}))
assert "LIMIT 25" in compiled
assert "OFFSET 50" in compiled
def test_resolve_repo_path_prefers_host_path() -> None:
repo = RepoStub()
assert resolve_repo_path(repo, "workstation") == "/host/path"
assert resolve_repo_path(repo, "unknown-host") == "/fallback/path"
def test_normalize_trailing_slash_preserves_query_and_fragment() -> None:
assert normalize_trailing_slash("/repos?status=active#top") == "/repos/?status=active#top"
assert normalize_trailing_slash("/repos/?status=active", trailing=False) == "/repos?status=active"

View File

@@ -0,0 +1,54 @@
---
id: HUB-WP-0001
type: workplan
title: "Bootstrap State Hub integration"
domain: inter_hub
repo: hub-core
status: ready
owner: codex
topic_slug: inter_hub
created: "2026-06-16"
updated: "2026-06-16"
---
# Bootstrap State Hub integration
**Updated:** 2026-06-16.
## Review Generated Integration Files
```task
id: HUB-WP-0001-T01
status: todo
priority: high
```
Review `INTENT.md`, `SCOPE.md`, `AGENTS.md`, and `.custodian-brief.md`.
Replace generated placeholders with repo-specific facts where needed.
## Verify Local Developer Workflow
```task
id: HUB-WP-0001-T02
status: todo
priority: high
```
Identify the repo's install, test, lint, build, and run commands. Add or refine
those commands in the agent instructions so future coding sessions can verify
changes confidently.
## Seed First Real Workplan
```task
id: HUB-WP-0001-T03
status: todo
priority: medium
```
Create the first implementation workplan for the repository's most important
next change. After workplan file updates, run from `~/state-hub`:
```bash
make fix-consistency REPO=hub-core
```