diff --git a/.custodian-brief.md b/.custodian-brief.md new file mode 100644 index 0000000..4fc1373 --- /dev/null +++ b/.custodian-brief.md @@ -0,0 +1,27 @@ + +# 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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2b0fc23 --- /dev/null +++ b/AGENTS.md @@ -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//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": "", + "task_id": "" + }' +``` + +Omit `workstream_id` / `task_id` when not applicable. + +### Update task status + +```bash +curl -s -X PATCH "http://127.0.0.1:8000/tasks/" \ + -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/" \ + -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-.md` + +**Archived location:** finished workplans may move to +`workplans/archived/YYMMDD-HUB-WP-NNNN-.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: "" # 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: "" # 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/`) diff --git a/INTENT.md b/INTENT.md new file mode 100644 index 0000000..4a5c602 --- /dev/null +++ b/INTENT.md @@ -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. \ No newline at end of file diff --git a/README.md b/README.md index fcd7b8f..2df4227 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,36 @@ -# repo-seed +# Hub Core -A git repository template to bootstrap coulomb projects from. \ No newline at end of file +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. diff --git a/SCOPE.md b/SCOPE.md new file mode 100644 index 0000000..4bab374 --- /dev/null +++ b/SCOPE.md @@ -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) \ No newline at end of file diff --git a/hub_core/__init__.py b/hub_core/__init__.py new file mode 100644 index 0000000..88e11e7 --- /dev/null +++ b/hub_core/__init__.py @@ -0,0 +1,5 @@ +"""Reusable primitives for FOS hub services.""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/hub_core/database.py b/hub_core/database.py new file mode 100644 index 0000000..a83bc28 --- /dev/null +++ b/hub_core/database.py @@ -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 diff --git a/hub_core/events.py b/hub_core/events.py new file mode 100644 index 0000000..82a4908 --- /dev/null +++ b/hub_core/events.py @@ -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", +] diff --git a/hub_core/mcp/__init__.py b/hub_core/mcp/__init__.py new file mode 100644 index 0000000..d07266d --- /dev/null +++ b/hub_core/mcp/__init__.py @@ -0,0 +1,3 @@ +from hub_core.mcp.server import HubCoreMCPServer + +__all__ = ["HubCoreMCPServer"] diff --git a/hub_core/mcp/server.py b/hub_core/mcp/server.py new file mode 100644 index 0000000..ccdb6e3 --- /dev/null +++ b/hub_core/mcp/server.py @@ -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) diff --git a/hub_core/migrations/__init__.py b/hub_core/migrations/__init__.py new file mode 100644 index 0000000..41cf2db --- /dev/null +++ b/hub_core/migrations/__init__.py @@ -0,0 +1 @@ +"""Alembic migration templates for hub-core adopters.""" diff --git a/hub_core/migrations/env.py b/hub_core/migrations/env.py new file mode 100644 index 0000000..90e95c3 --- /dev/null +++ b/hub_core/migrations/env.py @@ -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() diff --git a/hub_core/migrations/script.py.mako b/hub_core/migrations/script.py.mako new file mode 100644 index 0000000..590f5b3 --- /dev/null +++ b/hub_core/migrations/script.py.mako @@ -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"} diff --git a/hub_core/migrations/versions/0001_core_schema.py b/hub_core/migrations/versions/0001_core_schema.py new file mode 100644 index 0000000..0937714 --- /dev/null +++ b/hub_core/migrations/versions/0001_core_schema.py @@ -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") diff --git a/hub_core/migrations/versions/__init__.py b/hub_core/migrations/versions/__init__.py new file mode 100644 index 0000000..8768456 --- /dev/null +++ b/hub_core/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""hub-core Alembic version templates.""" diff --git a/hub_core/models/__init__.py b/hub_core/models/__init__.py new file mode 100644 index 0000000..4d78e46 --- /dev/null +++ b/hub_core/models/__init__.py @@ -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", +] diff --git a/hub_core/models/agent_message.py b/hub_core/models/agent_message.py new file mode 100644 index 0000000..8887503 --- /dev/null +++ b/hub_core/models/agent_message.py @@ -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", + ) diff --git a/hub_core/models/base.py b/hub_core/models/base.py new file mode 100644 index 0000000..ff2e80b --- /dev/null +++ b/hub_core/models/base.py @@ -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() diff --git a/hub_core/models/capability_catalog.py b/hub_core/models/capability_catalog.py new file mode 100644 index 0000000..96a87a2 --- /dev/null +++ b/hub_core/models/capability_catalog.py @@ -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 diff --git a/hub_core/models/capability_request.py b/hub_core/models/capability_request.py new file mode 100644 index 0000000..974dd6b --- /dev/null +++ b/hub_core/models/capability_request.py @@ -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 diff --git a/hub_core/models/domain.py b/hub_core/models/domain.py new file mode 100644 index 0000000..2b987ec --- /dev/null +++ b/hub_core/models/domain.py @@ -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" + ) diff --git a/hub_core/models/managed_repo.py b/hub_core/models/managed_repo.py new file mode 100644 index 0000000..4121d3c --- /dev/null +++ b/hub_core/models/managed_repo.py @@ -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 "" diff --git a/hub_core/models/progress_event.py b/hub_core/models/progress_event.py new file mode 100644 index 0000000..5d1069b --- /dev/null +++ b/hub_core/models/progress_event.py @@ -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 + ) diff --git a/hub_core/models/tpsc.py b/hub_core/models/tpsc.py new file mode 100644 index 0000000..4a186b7 --- /dev/null +++ b/hub_core/models/tpsc.py @@ -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") diff --git a/hub_core/routers/__init__.py b/hub_core/routers/__init__.py new file mode 100644 index 0000000..c40dfae --- /dev/null +++ b/hub_core/routers/__init__.py @@ -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", +] diff --git a/hub_core/routers/capabilities.py b/hub_core/routers/capabilities.py new file mode 100644 index 0000000..c155f7b --- /dev/null +++ b/hub_core/routers/capabilities.py @@ -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 diff --git a/hub_core/routers/domains.py b/hub_core/routers/domains.py new file mode 100644 index 0000000..d6e6c31 --- /dev/null +++ b/hub_core/routers/domains.py @@ -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()}, + ) diff --git a/hub_core/routers/messages.py b/hub_core/routers/messages.py new file mode 100644 index 0000000..054bde2 --- /dev/null +++ b/hub_core/routers/messages.py @@ -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 diff --git a/hub_core/routers/policy.py b/hub_core/routers/policy.py new file mode 100644 index 0000000..0c6b733 --- /dev/null +++ b/hub_core/routers/policy.py @@ -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 diff --git a/hub_core/routers/progress.py b/hub_core/routers/progress.py new file mode 100644 index 0000000..74877d7 --- /dev/null +++ b/hub_core/routers/progress.py @@ -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 diff --git a/hub_core/routers/repos.py b/hub_core/routers/repos.py new file mode 100644 index 0000000..72c6a60 --- /dev/null +++ b/hub_core/routers/repos.py @@ -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 diff --git a/hub_core/routers/tpsc.py b/hub_core/routers/tpsc.py new file mode 100644 index 0000000..28cecc4 --- /dev/null +++ b/hub_core/routers/tpsc.py @@ -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], + ) diff --git a/hub_core/schemas/__init__.py b/hub_core/schemas/__init__.py new file mode 100644 index 0000000..3b7b654 --- /dev/null +++ b/hub_core/schemas/__init__.py @@ -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", +] diff --git a/hub_core/schemas/agent_message.py b/hub_core/schemas/agent_message.py new file mode 100644 index 0000000..bdd06a7 --- /dev/null +++ b/hub_core/schemas/agent_message.py @@ -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 diff --git a/hub_core/schemas/capability.py b/hub_core/schemas/capability.py new file mode 100644 index 0000000..32f0501 --- /dev/null +++ b/hub_core/schemas/capability.py @@ -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 diff --git a/hub_core/schemas/doi.py b/hub_core/schemas/doi.py new file mode 100644 index 0000000..3587184 --- /dev/null +++ b/hub_core/schemas/doi.py @@ -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 diff --git a/hub_core/schemas/domain.py b/hub_core/schemas/domain.py new file mode 100644 index 0000000..deff27f --- /dev/null +++ b/hub_core/schemas/domain.py @@ -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) diff --git a/hub_core/schemas/managed_repo.py b/hub_core/schemas/managed_repo.py new file mode 100644 index 0000000..7327c22 --- /dev/null +++ b/hub_core/schemas/managed_repo.py @@ -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 diff --git a/hub_core/schemas/policy.py b/hub_core/schemas/policy.py new file mode 100644 index 0000000..a48c429 --- /dev/null +++ b/hub_core/schemas/policy.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel + + +class PolicyRead(BaseModel): + name: str + content: str + + +class PolicyUpdate(BaseModel): + content: str diff --git a/hub_core/schemas/progress_event.py b/hub_core/schemas/progress_event.py new file mode 100644 index 0000000..5bcf126 --- /dev/null +++ b/hub_core/schemas/progress_event.py @@ -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 diff --git a/hub_core/schemas/tpsc.py b/hub_core/schemas/tpsc.py new file mode 100644 index 0000000..3a4d450 --- /dev/null +++ b/hub_core/schemas/tpsc.py @@ -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] diff --git a/hub_core/utils/__init__.py b/hub_core/utils/__init__.py new file mode 100644 index 0000000..65c3f8e --- /dev/null +++ b/hub_core/utils/__init__.py @@ -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", +] diff --git a/hub_core/utils/pagination.py b/hub_core/utils/pagination.py new file mode 100644 index 0000000..49a5596 --- /dev/null +++ b/hub_core/utils/pagination.py @@ -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) diff --git a/hub_core/utils/paths.py b/hub_core/utils/paths.py new file mode 100644 index 0000000..ff2058c --- /dev/null +++ b/hub_core/utils/paths.py @@ -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 diff --git a/hub_core/utils/routing.py b/hub_core/utils/routing.py new file mode 100644 index 0000000..51a8603 --- /dev/null +++ b/hub_core/utils/routing.py @@ -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}" diff --git a/hub_core/utils/slugs.py b/hub_core/utils/slugs.py new file mode 100644 index 0000000..cafc4d5 --- /dev/null +++ b/hub_core/utils/slugs.py @@ -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("-") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f5aff60 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/registry/indexes/capabilities.yaml b/registry/indexes/capabilities.yaml index f944e47..b4f581b 100644 --- a/registry/indexes/capabilities.yaml +++ b/registry/indexes/capabilities.yaml @@ -1,4 +1,4 @@ version: 1 updated: '2026-06-16' -domain: helix_forge +domain: inter_hub capabilities: [] diff --git a/tests/test_imports.py b/tests/test_imports.py new file mode 100644 index 0000000..e7bf7c3 --- /dev/null +++ b/tests/test_imports.py @@ -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 diff --git a/tests/test_mcp.py b/tests/test_mcp.py new file mode 100644 index 0000000..23bc793 --- /dev/null +++ b/tests/test_mcp.py @@ -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 diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..39273fb --- /dev/null +++ b/tests/test_utils.py @@ -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" diff --git a/workplans/HUB-WP-0001-statehub-bootstrap.md b/workplans/HUB-WP-0001-statehub-bootstrap.md new file mode 100644 index 0000000..007253d --- /dev/null +++ b/workplans/HUB-WP-0001-statehub-bootstrap.md @@ -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 +```