generated from coulomb/repo-seed
Add hub-core package, docs, and State Hub integration scaffold
Extract the first reusable slice (models, schemas, routers, MCP, migrations) from state-hub with INTENT/SCOPE, agent instructions, workplan, and aligned inter_hub capability registry index.
This commit is contained in:
27
.custodian-brief.md
Normal file
27
.custodian-brief.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<!-- custodian-brief: generated by statehub register; fix-consistency may replace this file -->
|
||||||
|
# Custodian Brief - hub-core
|
||||||
|
|
||||||
|
**Project:** hub-core
|
||||||
|
**Domain:** inter_hub
|
||||||
|
**State Hub:** http://127.0.0.1:8000
|
||||||
|
**Topic ID:** `1f2e4d10-c967-4803-ae6c-7f4b4e806409`
|
||||||
|
|
||||||
|
## Open Workplans
|
||||||
|
|
||||||
|
### Bootstrap State Hub integration
|
||||||
|
|
||||||
|
Workplan file: `workplans/HUB-WP-0001-statehub-bootstrap.md`
|
||||||
|
|
||||||
|
Open tasks:
|
||||||
|
- T01 - Review generated integration files
|
||||||
|
- T02 - Verify local developer workflow
|
||||||
|
- T03 - Seed first real workplan
|
||||||
|
|
||||||
|
## Session Start
|
||||||
|
|
||||||
|
1. Read `INTENT.md`, `SCOPE.md`, and `AGENTS.md`.
|
||||||
|
2. Check inbox: `GET /messages/?to_agent=hub-core&unread_only=true`.
|
||||||
|
3. Scan `workplans/`.
|
||||||
|
4. Update task statuses in workplan files as work progresses.
|
||||||
|
|
||||||
|
Last generated: 2026-06-16
|
||||||
162
AGENTS.md
Normal file
162
AGENTS.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# hub-core — Agent Instructions
|
||||||
|
|
||||||
|
## Repo Identity
|
||||||
|
|
||||||
|
**Purpose:** **Updated:** 2026-06-16.
|
||||||
|
|
||||||
|
**Domain:** inter_hub
|
||||||
|
**Repo slug:** hub-core
|
||||||
|
**Topic ID:** `1f2e4d10-c967-4803-ae6c-7f4b4e806409`
|
||||||
|
**Workplan prefix:** `HUB-WP-`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State Hub Integration
|
||||||
|
|
||||||
|
The Custodian State Hub tracks work across all domains. Interact via HTTP REST —
|
||||||
|
there is no MCP server for Codex agents.
|
||||||
|
|
||||||
|
| Context | URL |
|
||||||
|
|---------|-----|
|
||||||
|
| Local workstation | `http://127.0.0.1:8000` |
|
||||||
|
| Remote via tunnel | `http://127.0.0.1:18000` |
|
||||||
|
|
||||||
|
### Orient at session start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Offline brief — works without hub connection
|
||||||
|
cat .custodian-brief.md
|
||||||
|
|
||||||
|
# Active workstreams for this domain
|
||||||
|
curl -s "http://127.0.0.1:8000/workstreams/?topic_id=1f2e4d10-c967-4803-ae6c-7f4b4e806409&status=active" \
|
||||||
|
| python3 -m json.tool
|
||||||
|
|
||||||
|
# Check inbox
|
||||||
|
curl -s "http://127.0.0.1:8000/messages/?to_agent=hub-core&unread_only=true" \
|
||||||
|
| python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
Mark a message read:
|
||||||
|
```bash
|
||||||
|
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
|
||||||
|
-H "Content-Type: application/json" -d '{}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Log progress (required at session close)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://127.0.0.1:8000/progress/ \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"summary": "what was done",
|
||||||
|
"event_type": "note",
|
||||||
|
"author": "codex",
|
||||||
|
"workstream_id": "<uuid>",
|
||||||
|
"task_id": "<uuid>"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Omit `workstream_id` / `task_id` when not applicable.
|
||||||
|
|
||||||
|
### Update task status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"status": "progress"}'
|
||||||
|
# values: wait | todo | progress | done | cancel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flag a task for human review
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"needs_human": true, "intervention_note": "reason"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session Protocol
|
||||||
|
|
||||||
|
**Start:**
|
||||||
|
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
|
||||||
|
2. Check inbox: `GET /messages/?to_agent=hub-core&unread_only=true`; mark read
|
||||||
|
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
|
||||||
|
4. Check human-needed tasks: `GET /tasks/?needs_human=true`
|
||||||
|
|
||||||
|
**During work:**
|
||||||
|
- Update task statuses in workplan files as tasks progress
|
||||||
|
- Record significant decisions via `POST /decisions/`
|
||||||
|
|
||||||
|
**Close:**
|
||||||
|
1. Update workplan file task statuses to reflect progress
|
||||||
|
2. Log: `POST /progress/` with a summary of what changed
|
||||||
|
3. Note for the custodian operator: after workplan file changes, run from
|
||||||
|
`~/state-hub`:
|
||||||
|
```bash
|
||||||
|
make fix-consistency REPO=hub-core
|
||||||
|
```
|
||||||
|
This syncs task status from files into the hub DB.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workplan Convention (ADR-001)
|
||||||
|
|
||||||
|
Work items originate as files in this repo — not in the hub. The hub is a
|
||||||
|
read/cache/index layer that rebuilds from files.
|
||||||
|
|
||||||
|
**File location:** `workplans/HUB-WP-NNNN-<slug>.md`
|
||||||
|
|
||||||
|
**Archived location:** finished workplans may move to
|
||||||
|
`workplans/archived/YYMMDD-HUB-WP-NNNN-<slug>.md`. The `YYMMDD` prefix is
|
||||||
|
the completion/archive date; the frontmatter `id` does not change.
|
||||||
|
|
||||||
|
**Ad Hoc Tasks:** small opportunistic fixes discovered during a session use
|
||||||
|
`workplans/ADHOC-YYYY-MM-DD.md` with task ids `ADHOC-YYYY-MM-DD-T01`, etc. Use
|
||||||
|
this only for low-risk work completed directly; create a normal workplan for
|
||||||
|
anything needing analysis, design, approval, dependencies, or multiple phases.
|
||||||
|
|
||||||
|
**Frontmatter:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
id: HUB-WP-NNNN
|
||||||
|
type: workplan
|
||||||
|
title: "..."
|
||||||
|
domain: inter_hub
|
||||||
|
repo: hub-core
|
||||||
|
status: proposed | ready | active | blocked | backlog | finished | archived
|
||||||
|
owner: codex
|
||||||
|
topic_slug: ...
|
||||||
|
created: "YYYY-MM-DD"
|
||||||
|
updated: "YYYY-MM-DD"
|
||||||
|
state_hub_workstream_id: "<uuid>" # written by fix-consistency — do not edit
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `proposed` for a new draft, `ready` after review against current repo
|
||||||
|
state, and `finished` after implementation. `stalled` and `needs_review` are
|
||||||
|
derived health labels, not frontmatter statuses.
|
||||||
|
|
||||||
|
**Task block format** (one per `##` section):
|
||||||
|
|
||||||
|
```
|
||||||
|
## Task Title
|
||||||
|
|
||||||
|
` ` `task
|
||||||
|
id: HUB-WP-NNNN-T01
|
||||||
|
status: wait | todo | progress | done | cancel
|
||||||
|
priority: high | medium | low
|
||||||
|
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
|
||||||
|
` ` `
|
||||||
|
|
||||||
|
Task description text.
|
||||||
|
```
|
||||||
|
|
||||||
|
Status progression: `todo` → `progress` → `done`; use `wait` for waiting/blocked work and `cancel` for stopped work.
|
||||||
|
|
||||||
|
To create a new workplan:
|
||||||
|
1. Write the file following the format above
|
||||||
|
2. Notify the custodian operator to run `make fix-consistency REPO=hub-core`
|
||||||
|
(or send a message to the hub agent via `POST /messages/`)
|
||||||
137
INTENT.md
Normal file
137
INTENT.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# INTENT — hub-core
|
||||||
|
|
||||||
|
**Project:** `hub-core`
|
||||||
|
**Domain:** `inter_hub`
|
||||||
|
**Status:** Active extraction (CUST-WP-0025)
|
||||||
|
**Updated:** 2026-06-16
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## One-line intent
|
||||||
|
|
||||||
|
`hub-core` provides reusable FastAPI, SQLAlchemy, and MCP primitives so multiple
|
||||||
|
FOS hub services can share a common foundation without importing each other's
|
||||||
|
domain-specific coordination models.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why it exists
|
||||||
|
|
||||||
|
Custodian and helix_forge ecosystems need more than one hub-shaped service:
|
||||||
|
development coordination (`state-hub`), operations (`ops-hub`), finance, and
|
||||||
|
future domain hubs. Those services repeat the same patterns — domain registry,
|
||||||
|
managed repositories, agent messaging, progress telemetry, capability catalog
|
||||||
|
surfaces, third-party service catalog (TPSC), policy lookup, and MCP
|
||||||
|
orientation tools.
|
||||||
|
|
||||||
|
Without `hub-core`, each hub would duplicate SQLAlchemy models, Pydantic
|
||||||
|
contracts, router mounting, pagination helpers, and MCP wrappers. That leads to
|
||||||
|
schema drift, incompatible agent tools, and expensive extractions every time a
|
||||||
|
second hub appears.
|
||||||
|
|
||||||
|
`hub-core` exists to extract the **generic hub substrate** once and let each hub
|
||||||
|
package own only its domain-specific tables, workflows, and policies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Governing principle
|
||||||
|
|
||||||
|
> **Hub-core is a library, not a hub.**
|
||||||
|
|
||||||
|
It ships models, schemas, router factories, migration scaffolds, utilities, and
|
||||||
|
an optional FastMCP base server. A consuming repository (for example
|
||||||
|
`state-hub`) wires database sessions, auth, host-specific callbacks, and
|
||||||
|
domain-only routes into those factories.
|
||||||
|
|
||||||
|
`hub-core` should answer:
|
||||||
|
|
||||||
|
1. **What primitives do all FOS hubs share?** Domains, repos, messages,
|
||||||
|
progress events, capability catalog/request read paths, TPSC catalog/snapshots,
|
||||||
|
policy lookup, canonical risk/alert event types.
|
||||||
|
2. **How do hubs expose them consistently?** Factory-based FastAPI routers and
|
||||||
|
matching MCP tools with dependency injection at the host boundary.
|
||||||
|
3. **How do hubs evolve schema together?** Shared Alembic templates and a
|
||||||
|
documented core-schema migration adopters can extend.
|
||||||
|
|
||||||
|
It should **not** answer dev-hub questions such as which workstream is blocked,
|
||||||
|
which task needs human review, or how kaizen agents spawn maintenance work. Those
|
||||||
|
remain in `state-hub` and other host implementations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What it is
|
||||||
|
|
||||||
|
`hub-core` is the **shared Python package** for FOS hub services.
|
||||||
|
|
||||||
|
Current package surface (`hub_core/`):
|
||||||
|
|
||||||
|
| Area | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `models/` | SQLAlchemy base, domains, managed repos, agent messages, capability catalog/requests, progress events, TPSC |
|
||||||
|
| `schemas/` | Pydantic contracts matching core models plus DoI report shapes |
|
||||||
|
| `routers/` | Factory functions: domains, repos, messages, progress, capabilities, TPSC, policy |
|
||||||
|
| `mcp/` | `HubCoreMCPServer` — generic orientation, messaging, capability, repo, DoI, TPSC/GDPR, risk/alert, progress tools |
|
||||||
|
| `migrations/` | Alembic scaffold and `0001_core_schema` for adopters |
|
||||||
|
| `utils/` | Slugs, pagination, repo path resolution, trailing-slash routing |
|
||||||
|
| `events.py` | Canonical FOS §10 risk and alert event types |
|
||||||
|
|
||||||
|
Hosts mount only the routers they need and inject their own `Session` providers,
|
||||||
|
models where extended, and workflow callbacks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What it is not
|
||||||
|
|
||||||
|
| Concern | Owner |
|
||||||
|
|---|---|
|
||||||
|
| Running production hub deployment | `state-hub`, `ops-hub`, future hubs |
|
||||||
|
| Topics, workstreams, tasks, decisions, SBOM, token accounting | `state-hub` (dev-hub layer) |
|
||||||
|
| Custodian canon, constitution, domain charters | `the-custodian` |
|
||||||
|
| Event-triggered maintenance task creation | `activity-core` |
|
||||||
|
| General issue/task lifecycle outside Custodian workplans | `issue-core` |
|
||||||
|
| Capability reuse registry and federation compose | `reuse-surface` |
|
||||||
|
| Network tunnels and remote operations | `ops-bridge` |
|
||||||
|
|
||||||
|
`hub-core` may define generic capability **catalog** and **request read**
|
||||||
|
primitives, but workflow side effects (task unblocking, dispute resolution,
|
||||||
|
acceptance flows) stay in the host hub.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Primary consumers
|
||||||
|
|
||||||
|
| Consumer | Relationship |
|
||||||
|
|---|---|
|
||||||
|
| `state-hub` | First adopter; incremental import of schemas, routers, MCP (CUST-WP-0025 T08+) |
|
||||||
|
| `ops-hub` | Planned consumer of shared primitives without dev-hub tables |
|
||||||
|
| Future FOS hubs | Fin-hub and domain hubs mount subsets of hub-core factories |
|
||||||
|
|
||||||
|
Extraction boundary and migration status:
|
||||||
|
`/home/worsch/the-custodian/docs/hub-core-extraction-boundary.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success criteria
|
||||||
|
|
||||||
|
`hub-core` succeeds when:
|
||||||
|
|
||||||
|
- a new hub can register domains and repos using hub-core routers without copying SQLAlchemy models
|
||||||
|
- State Hub pytest suite passes with hub-core as an editable dependency
|
||||||
|
- MCP tools for orientation, messages, progress, and TPSC behave consistently across hosts that opt in
|
||||||
|
- schema changes to shared primitives are versioned through hub-core migrations, not ad hoc forks
|
||||||
|
- dev-hub-specific foreign keys never appear in hub-core models (extension via host callbacks or JSON context fields)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Replacing FastAPI, SQLAlchemy, or FastMCP
|
||||||
|
- Owning PostgreSQL instance provisioning for any environment
|
||||||
|
- Becoming a general application framework unrelated to hub-shaped services
|
||||||
|
- Absorbing reuse-surface capability maturity registry semantics
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Working mantra
|
||||||
|
|
||||||
|
> Extract once what every hub needs; keep domain drama in the hub that owns it.
|
||||||
37
README.md
37
README.md
@@ -1,3 +1,36 @@
|
|||||||
# repo-seed
|
# Hub Core
|
||||||
|
|
||||||
A git repository template to bootstrap coulomb projects from.
|
Reusable FastAPI, SQLAlchemy, and MCP primitives for FOS hubs.
|
||||||
|
|
||||||
|
`hub-core` is being extracted from the standalone State Hub repository as part
|
||||||
|
of `CUST-WP-0025`. The initial package slice contains only the generic database
|
||||||
|
models and schemas that can move without importing dev-hub concepts such as
|
||||||
|
topics, workstreams, tasks, decisions, SBOM, or token accounting.
|
||||||
|
|
||||||
|
Source boundary notes live in:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/home/worsch/the-custodian/docs/hub-core-extraction-boundary.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## First Slice
|
||||||
|
|
||||||
|
- SQLAlchemy base metadata and timestamp helpers.
|
||||||
|
- Domain and managed-repository registry primitives.
|
||||||
|
- Agent message inbox primitives.
|
||||||
|
- Progress-event and capability-request primitives with generic JSON context
|
||||||
|
fields for hub-specific references.
|
||||||
|
- Third-party service catalog and snapshot primitives.
|
||||||
|
- Matching Pydantic schemas for those primitives.
|
||||||
|
- Generic DoI report and summary schemas used by the MCP DoI tools.
|
||||||
|
- Router factory functions for domains, repos, messages, policy lookup, and
|
||||||
|
progress, capability, and TPSC catalog/snapshot/report endpoints.
|
||||||
|
- Canonical FOS §10 risk and alert event types with `/progress/risks` and
|
||||||
|
`/progress/alerts` read views.
|
||||||
|
- Shared utility helpers for slugs, pagination, repo path resolution, and
|
||||||
|
trailing-slash path normalization.
|
||||||
|
- Alembic templates plus an initial core-schema migration for hub adopters.
|
||||||
|
- FastMCP base-server wrapper for generic orientation, messaging, capability,
|
||||||
|
repo, DoI, TPSC/GDPR, risk/alert, and progress tools.
|
||||||
|
|
||||||
|
Domain-specific MCP tools follow in each hub package.
|
||||||
|
|||||||
152
SCOPE.md
Normal file
152
SCOPE.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# SCOPE — hub-core
|
||||||
|
|
||||||
|
**Updated:** 2026-06-16
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## One-liner
|
||||||
|
|
||||||
|
Reusable Python package of FastAPI router factories, SQLAlchemy models, Pydantic
|
||||||
|
schemas, MCP tooling, and migration scaffolds for FOS hub services.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core idea
|
||||||
|
|
||||||
|
`hub-core` is a **library boundary** between shared hub infrastructure and
|
||||||
|
host-specific hub implementations. Host repositories depend on `hub-core` as an
|
||||||
|
editable or published package; they run the actual HTTP/MCP service, own
|
||||||
|
deployment, and add domain tables and workflows on top.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## In scope
|
||||||
|
|
||||||
|
- **`hub_core` Python package** — models, schemas, routers, MCP server wrapper,
|
||||||
|
utilities, events, database helpers
|
||||||
|
- **Router factories** with host-injected sessions, models, and callbacks
|
||||||
|
(domains, repos, messages, progress, capabilities, TPSC, policy)
|
||||||
|
- **Alembic migration scaffold** for core tables adopters extend
|
||||||
|
- **Tests** under `tests/` proving import seams and MCP behavior
|
||||||
|
- **Package metadata** — `pyproject.toml`, hatchling wheel build
|
||||||
|
- **Capability registry scaffold** — `registry/` per helix_forge federation
|
||||||
|
contract (entries added when reusable behaviors are registered)
|
||||||
|
- **Documentation** — `README.md`, `INTENT.md`, `SCOPE.md`, pointer to
|
||||||
|
extraction boundary in `the-custodian`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Long-running hub service, Docker image, or production URL for hub-core itself
|
||||||
|
- Dev-hub tables: topics, workstreams, tasks, decisions, dependencies, SBOM,
|
||||||
|
token accounting, kaizen agents
|
||||||
|
- State Hub dashboard UI, consistency sync scripts, and workplan file authority
|
||||||
|
- Custodian canon content and constitution maintenance
|
||||||
|
- Plaintext secrets, environment-specific connection strings committed to git
|
||||||
|
- Replacing or wrapping non-hub application domains (feature-control, reuse-surface, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is possible now
|
||||||
|
|
||||||
|
After the CUST-WP-0025 first slice (2026-06-06 — 2026-06-07):
|
||||||
|
|
||||||
|
| Capability | Status |
|
||||||
|
|---|---|
|
||||||
|
| Install as editable package | `pip install -e .` / uv equivalent |
|
||||||
|
| Import core models and schemas | `hub_core.models`, `hub_core.schemas` |
|
||||||
|
| Mount generic routers in a host FastAPI app | `hub_core.routers.create_*_router` |
|
||||||
|
| Run generic MCP tools via `HubCoreMCPServer` | `hub_core.mcp` |
|
||||||
|
| Apply core-schema migration template | `hub_core/migrations/versions/0001_core_schema.py` |
|
||||||
|
| Adopt shared slug/pagination/path utilities | `hub_core.utils` |
|
||||||
|
| Expose risk/alert progress read views | `/progress/risks`, `/progress/alerts` patterns |
|
||||||
|
| State Hub incremental adoption | Schemas, messages, policy, TPSC, progress, domains, capability catalog routers imported |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/hub-core
|
||||||
|
python3 -m venv .venv && .venv/bin/pip install -e .
|
||||||
|
.venv/bin/pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is not possible yet
|
||||||
|
|
||||||
|
- **Published PyPI package** — consumed via editable path or private index only
|
||||||
|
- **Standalone `hub-core serve`** — no CLI entrypoint; hosts own `uvicorn`
|
||||||
|
- **Complete State Hub decoupling** — dev-hub routes and models still live in `state-hub`
|
||||||
|
- **ops-hub / fin-hub adoption** — planned; not verified in this repo
|
||||||
|
- **Capability registry entries** — scaffold only (`capabilities: []`); no registered reusable behaviors yet
|
||||||
|
- **Gitea federation publish** — repo not yet on Gitea; blocks T01 in reuse-surface WP-0015
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current state
|
||||||
|
|
||||||
|
| Item | Value |
|
||||||
|
|---|---|
|
||||||
|
| Package version | `0.1.0` (`hub_core.__version__`) |
|
||||||
|
| Python | `>=3.12` |
|
||||||
|
| Dependencies | FastAPI, FastMCP, SQLAlchemy, Pydantic, httpx |
|
||||||
|
| Tests | pytest under `tests/` |
|
||||||
|
| Registry | Empty capability index; federation scaffold present |
|
||||||
|
| Primary consumer | `state-hub` (editable dependency, router/schema import in progress) |
|
||||||
|
| Extraction workplan | `CUST-WP-0025` (custodian domain) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
hub-core/
|
||||||
|
├── INTENT.md
|
||||||
|
├── SCOPE.md
|
||||||
|
├── README.md
|
||||||
|
├── pyproject.toml
|
||||||
|
├── hub_core/
|
||||||
|
│ ├── models/
|
||||||
|
│ ├── schemas/
|
||||||
|
│ ├── routers/
|
||||||
|
│ ├── mcp/
|
||||||
|
│ ├── migrations/
|
||||||
|
│ ├── utils/
|
||||||
|
│ ├── database.py
|
||||||
|
│ └── events.py
|
||||||
|
├── registry/
|
||||||
|
│ ├── capabilities/
|
||||||
|
│ └── indexes/capabilities.yaml
|
||||||
|
└── tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Boundaries with sibling repos
|
||||||
|
|
||||||
|
| Repo | Boundary |
|
||||||
|
|---|---|
|
||||||
|
| `state-hub` | Host dev-hub; imports hub-core factories; keeps workstream/task/decision logic |
|
||||||
|
| `the-custodian` | Owns extraction boundary doc and CUST-WP-0025 workplan |
|
||||||
|
| `reuse-surface` | Federation hub for capability indexes; not a runtime dependency of hub-core |
|
||||||
|
| `ops-hub` | Future consumer; operations-specific tables stay local |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workplan convention
|
||||||
|
|
||||||
|
Hub-core extraction and package work is tracked under **custodian** workplans
|
||||||
|
(for example `CUST-WP-0025`). Host adoption milestones are tracked in
|
||||||
|
`state-hub` workplans (for example `CUST-WP-0048`).
|
||||||
|
|
||||||
|
When hub-core gains repo-local workplans, prefer a stable prefix agreed with
|
||||||
|
custodian operators (for example `HUBCORE-WP-####`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting oriented
|
||||||
|
|
||||||
|
- Product intent: `INTENT.md`
|
||||||
|
- Extraction boundary: `/home/worsch/the-custodian/docs/hub-core-extraction-boundary.md`
|
||||||
|
- Package entry: `hub_core/__init__.py`, `hub_core/routers/__init__.py`
|
||||||
|
- Consumer example: `/home/worsch/state-hub` (editable `hub-core` dependency)
|
||||||
|
- Federation registry: `registry/README.md` (reuse-surface contract)
|
||||||
5
hub_core/__init__.py
Normal file
5
hub_core/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Reusable primitives for FOS hub services."""
|
||||||
|
|
||||||
|
__all__ = ["__version__"]
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
18
hub_core/database.py
Normal file
18
hub_core/database.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
|
||||||
|
def make_engine(database_url: str, **kwargs: object) -> AsyncEngine:
|
||||||
|
return create_async_engine(database_url, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def make_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
|
||||||
|
return async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def session_from_factory(
|
||||||
|
factory: async_sessionmaker[AsyncSession],
|
||||||
|
) -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
async with factory() as session:
|
||||||
|
yield session
|
||||||
37
hub_core/events.py
Normal file
37
hub_core/events.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""Canonical event types for reusable FOS hub progress streams."""
|
||||||
|
|
||||||
|
RISK_SURFACED = "risk_surfaced"
|
||||||
|
RISK_MITIGATED = "risk_mitigated"
|
||||||
|
RISK_ESCALATED = "risk_escalated"
|
||||||
|
|
||||||
|
ALERT_RAISED = "alert_raised"
|
||||||
|
ALERT_ACKNOWLEDGED = "alert_acknowledged"
|
||||||
|
ALERT_RESOLVED = "alert_resolved"
|
||||||
|
|
||||||
|
RISK_EVENT_TYPES = frozenset(
|
||||||
|
{
|
||||||
|
RISK_SURFACED,
|
||||||
|
RISK_MITIGATED,
|
||||||
|
RISK_ESCALATED,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ALERT_EVENT_TYPES = frozenset(
|
||||||
|
{
|
||||||
|
ALERT_RAISED,
|
||||||
|
ALERT_ACKNOWLEDGED,
|
||||||
|
ALERT_RESOLVED,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
FOS10_EVENT_TYPES = RISK_EVENT_TYPES | ALERT_EVENT_TYPES
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ALERT_ACKNOWLEDGED",
|
||||||
|
"ALERT_EVENT_TYPES",
|
||||||
|
"ALERT_RAISED",
|
||||||
|
"ALERT_RESOLVED",
|
||||||
|
"FOS10_EVENT_TYPES",
|
||||||
|
"RISK_ESCALATED",
|
||||||
|
"RISK_EVENT_TYPES",
|
||||||
|
"RISK_MITIGATED",
|
||||||
|
"RISK_SURFACED",
|
||||||
|
]
|
||||||
3
hub_core/mcp/__init__.py
Normal file
3
hub_core/mcp/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from hub_core.mcp.server import HubCoreMCPServer
|
||||||
|
|
||||||
|
__all__ = ["HubCoreMCPServer"]
|
||||||
411
hub_core/mcp/server.py
Normal file
411
hub_core/mcp/server.py
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
|
from hub_core.utils.routing import normalize_trailing_slash
|
||||||
|
|
||||||
|
|
||||||
|
class HubCoreMCPServer:
|
||||||
|
"""FastMCP base server for generic FOS hub tools.
|
||||||
|
|
||||||
|
The MCP layer is intentionally a thin HTTP client. Hubs keep their business
|
||||||
|
rules in FastAPI routers and inject only the API base URL here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
api_base: str,
|
||||||
|
instructions: str | None = None,
|
||||||
|
register_tools: bool = True,
|
||||||
|
) -> None:
|
||||||
|
self.api_base = api_base.rstrip("/")
|
||||||
|
self.mcp = FastMCP(
|
||||||
|
name=name,
|
||||||
|
instructions=instructions or "Generic FOS hub MCP server.",
|
||||||
|
)
|
||||||
|
if register_tools:
|
||||||
|
self.register_core_tools()
|
||||||
|
|
||||||
|
def register_core_tools(self) -> None:
|
||||||
|
@self.mcp.tool()
|
||||||
|
def get_state_summary() -> str:
|
||||||
|
return self._json(self._get("/state/summary/"))
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def list_domains(status: str | None = None) -> str:
|
||||||
|
return self._json(self._get("/domains/", {"status": status}))
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def get_domain_summary(domain_slug: str) -> str:
|
||||||
|
return self._json(self._get(f"/domains/{domain_slug}/"))
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def get_domain(domain_slug: str) -> str:
|
||||||
|
return self._json(self._get(f"/domains/{domain_slug}/"))
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def send_message(
|
||||||
|
from_agent: str,
|
||||||
|
to_agent: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
thread_id: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
return self._json(
|
||||||
|
self._post(
|
||||||
|
"/messages/",
|
||||||
|
{
|
||||||
|
"from_agent": from_agent,
|
||||||
|
"to_agent": to_agent,
|
||||||
|
"subject": subject,
|
||||||
|
"body": body,
|
||||||
|
"thread_id": thread_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def get_messages(
|
||||||
|
to_agent: str | None = None,
|
||||||
|
from_agent: str | None = None,
|
||||||
|
unread_only: bool = False,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> str:
|
||||||
|
return self._json(
|
||||||
|
self._get(
|
||||||
|
"/messages/",
|
||||||
|
{
|
||||||
|
"to_agent": to_agent,
|
||||||
|
"from_agent": from_agent,
|
||||||
|
"unread_only": unread_only,
|
||||||
|
"limit": limit,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def mark_message_read(message_id: str) -> str:
|
||||||
|
return self._json(self._patch(f"/messages/{message_id}/read/", {}))
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def reply_to_message(message_id: str, from_agent: str, body: str) -> str:
|
||||||
|
return self._json(
|
||||||
|
self._post(
|
||||||
|
f"/messages/{message_id}/reply/",
|
||||||
|
{"from_agent": from_agent, "body": body},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def register_capability(
|
||||||
|
domain: str,
|
||||||
|
capability_type: str,
|
||||||
|
title: str,
|
||||||
|
description: str | None = None,
|
||||||
|
keywords: list[str] | None = None,
|
||||||
|
repo_slug: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
return self._json(
|
||||||
|
self._post(
|
||||||
|
"/capability-catalog/",
|
||||||
|
{
|
||||||
|
"domain": domain,
|
||||||
|
"capability_type": capability_type,
|
||||||
|
"title": title,
|
||||||
|
"description": description,
|
||||||
|
"keywords": keywords or [],
|
||||||
|
"repo_slug": repo_slug,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def list_capabilities(
|
||||||
|
domain: str | None = None,
|
||||||
|
capability_type: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
return self._json(
|
||||||
|
self._get(
|
||||||
|
"/capability-catalog/",
|
||||||
|
{"domain": domain, "capability_type": capability_type, "status": status},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def request_capability(
|
||||||
|
title: str,
|
||||||
|
capability_type: str,
|
||||||
|
requesting_domain: str,
|
||||||
|
requesting_agent: str,
|
||||||
|
description: str | None = None,
|
||||||
|
priority: str = "medium",
|
||||||
|
request_context: dict[str, Any] | None = None,
|
||||||
|
catalog_entry_id: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
return self._json(
|
||||||
|
self._post(
|
||||||
|
"/capability-requests/",
|
||||||
|
{
|
||||||
|
"title": title,
|
||||||
|
"description": description,
|
||||||
|
"capability_type": capability_type,
|
||||||
|
"priority": priority,
|
||||||
|
"requesting_domain": requesting_domain,
|
||||||
|
"requesting_agent": requesting_agent,
|
||||||
|
"request_context": request_context,
|
||||||
|
"catalog_entry_id": catalog_entry_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def accept_capability_request(
|
||||||
|
request_id: str,
|
||||||
|
fulfilling_agent: str,
|
||||||
|
fulfillment_context: dict[str, Any] | None = None,
|
||||||
|
) -> str:
|
||||||
|
return self._json(
|
||||||
|
self._post(
|
||||||
|
f"/capability-requests/{request_id}/accept/",
|
||||||
|
{
|
||||||
|
"fulfilling_agent": fulfilling_agent,
|
||||||
|
"fulfillment_context": fulfillment_context,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def update_capability_request_status(
|
||||||
|
request_id: str,
|
||||||
|
status: str,
|
||||||
|
note: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
return self._json(
|
||||||
|
self._patch(
|
||||||
|
f"/capability-requests/{request_id}/status/",
|
||||||
|
{"status": status, "note": note},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def list_capability_requests(
|
||||||
|
domain: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
capability_type: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
return self._json(
|
||||||
|
self._get(
|
||||||
|
"/capability-requests/",
|
||||||
|
{"domain": domain, "status": status, "capability_type": capability_type},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def get_capability_request(request_id: str) -> str:
|
||||||
|
return self._json(self._get(f"/capability-requests/{request_id}/"))
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def register_repo(
|
||||||
|
domain_slug: str,
|
||||||
|
slug: str,
|
||||||
|
name: str,
|
||||||
|
local_path: str | None = None,
|
||||||
|
remote_url: str | None = None,
|
||||||
|
git_fingerprint: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
return self._json(
|
||||||
|
self._post(
|
||||||
|
"/repos/",
|
||||||
|
{
|
||||||
|
"domain_slug": domain_slug,
|
||||||
|
"slug": slug,
|
||||||
|
"name": name,
|
||||||
|
"local_path": local_path,
|
||||||
|
"remote_url": remote_url,
|
||||||
|
"git_fingerprint": git_fingerprint,
|
||||||
|
"description": description,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def update_repo_path(repo_slug: str, host: str, path: str) -> str:
|
||||||
|
return self._json(
|
||||||
|
self._post(f"/repos/{repo_slug}/paths/", {"host": host, "path": path})
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def list_domain_repos(domain_slug: str) -> str:
|
||||||
|
return self._json(self._get("/repos/", {"domain": domain_slug}))
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def check_repo_doi(repo_slug: str, force_refresh: bool = False) -> str:
|
||||||
|
return self._json(
|
||||||
|
self._get(
|
||||||
|
f"/repos/{repo_slug}/doi/",
|
||||||
|
{"force_refresh": force_refresh},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def get_doi_summary() -> str:
|
||||||
|
return self._json(self._get("/repos/doi/summary/"))
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def register_service(
|
||||||
|
slug: str,
|
||||||
|
name: str,
|
||||||
|
provider: str | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
website_url: str | None = None,
|
||||||
|
pricing_model: str = "unknown",
|
||||||
|
gdpr_maturity: str = "unknown",
|
||||||
|
) -> str:
|
||||||
|
return self._json(
|
||||||
|
self._post(
|
||||||
|
"/tpsc/catalog/",
|
||||||
|
{
|
||||||
|
"slug": slug,
|
||||||
|
"name": name,
|
||||||
|
"provider": provider,
|
||||||
|
"category": category,
|
||||||
|
"website_url": website_url,
|
||||||
|
"pricing_model": pricing_model,
|
||||||
|
"gdpr_maturity": gdpr_maturity,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def list_services(
|
||||||
|
gdpr_maturity: str | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
pricing_model: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
return self._json(
|
||||||
|
self._get(
|
||||||
|
"/tpsc/catalog/",
|
||||||
|
{
|
||||||
|
"gdpr_maturity": gdpr_maturity,
|
||||||
|
"category": category,
|
||||||
|
"pricing_model": pricing_model,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def ingest_tpsc_tool(repo_slug: str, source_file: str, entries: list[dict[str, Any]]) -> str:
|
||||||
|
return self._json(
|
||||||
|
self._post(
|
||||||
|
"/tpsc/ingest/",
|
||||||
|
{"repo_slug": repo_slug, "source_file": source_file, "entries": entries},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def get_gdpr_report() -> str:
|
||||||
|
return self._json(self._get("/tpsc/report/gdpr/"))
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def get_risks(
|
||||||
|
since: str | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> str:
|
||||||
|
return self._json(
|
||||||
|
self._get(
|
||||||
|
"/progress/risks/",
|
||||||
|
{"since": since, "limit": limit, "offset": offset},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def get_alerts(
|
||||||
|
since: str | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> str:
|
||||||
|
return self._json(
|
||||||
|
self._get(
|
||||||
|
"/progress/alerts/",
|
||||||
|
{"since": since, "limit": limit, "offset": offset},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.mcp.tool()
|
||||||
|
def append_progress(
|
||||||
|
event_type: str,
|
||||||
|
summary: str,
|
||||||
|
detail: dict[str, Any] | None = None,
|
||||||
|
subject_refs: dict[str, Any] | None = None,
|
||||||
|
author: str | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
return self._json(
|
||||||
|
self._post(
|
||||||
|
"/progress/",
|
||||||
|
{
|
||||||
|
"event_type": event_type,
|
||||||
|
"summary": summary,
|
||||||
|
"detail": detail,
|
||||||
|
"subject_refs": subject_refs,
|
||||||
|
"author": author,
|
||||||
|
"session_id": session_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get(self, path: str, params: dict[str, Any] | None = None) -> Any:
|
||||||
|
try:
|
||||||
|
with self._client() as client:
|
||||||
|
response = client.get(
|
||||||
|
normalize_trailing_slash(path),
|
||||||
|
params=self._clean(params or {}),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
return {"error": f"API {exc.response.status_code}: {exc.response.text[:300]}"}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"error": f"Request failed: {exc}"}
|
||||||
|
|
||||||
|
def _post(self, path: str, body: dict[str, Any]) -> Any:
|
||||||
|
try:
|
||||||
|
with self._client() as client:
|
||||||
|
response = client.post(normalize_trailing_slash(path), json=self._clean(body))
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
return {"error": f"API {exc.response.status_code}: {exc.response.text[:300]}"}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"error": f"Request failed: {exc}"}
|
||||||
|
|
||||||
|
def _patch(self, path: str, body: dict[str, Any]) -> Any:
|
||||||
|
try:
|
||||||
|
with self._client() as client:
|
||||||
|
response = client.patch(normalize_trailing_slash(path), json=self._clean(body))
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
return {"error": f"API {exc.response.status_code}: {exc.response.text[:300]}"}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"error": f"Request failed: {exc}"}
|
||||||
|
|
||||||
|
def _client(self) -> httpx.Client:
|
||||||
|
return httpx.Client(base_url=self.api_base, timeout=30.0, follow_redirects=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clean(data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {key: value for key, value in data.items() if value is not None}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _json(data: Any) -> str:
|
||||||
|
return json.dumps(data, indent=2, default=str)
|
||||||
1
hub_core/migrations/__init__.py
Normal file
1
hub_core/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Alembic migration templates for hub-core adopters."""
|
||||||
49
hub_core/migrations/env.py
Normal file
49
hub_core/migrations/env.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import os
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
|
from hub_core.models import Base
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
db_url = os.environ.get("DATABASE_URL")
|
||||||
|
if db_url:
|
||||||
|
sync_url = db_url.replace("postgresql+asyncpg://", "postgresql+psycopg2://")
|
||||||
|
config.set_main_option("sqlalchemy.url", sync_url)
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
24
hub_core/migrations/script.py.mako
Normal file
24
hub_core/migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
202
hub_core/migrations/versions/0001_core_schema.py
Normal file
202
hub_core/migrations/versions/0001_core_schema.py
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
"""hub-core core schema
|
||||||
|
|
||||||
|
Revision ID: 0001_core_schema
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-06-06
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "0001_core_schema"
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"domains",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("slug", sa.String(50), nullable=False, unique=True),
|
||||||
|
sa.Column("name", sa.String(200), nullable=False),
|
||||||
|
sa.Column("description", sa.Text, nullable=True),
|
||||||
|
sa.Column("status", sa.String(20), nullable=False, server_default="active"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_domains_slug", "domains", ["slug"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"managed_repos",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("domain_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("domains.id", ondelete="RESTRICT"), nullable=False),
|
||||||
|
sa.Column("slug", sa.String(100), nullable=False, unique=True),
|
||||||
|
sa.Column("name", sa.String(200), nullable=False),
|
||||||
|
sa.Column("local_path", sa.Text, nullable=True),
|
||||||
|
sa.Column("host_paths", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="{}"),
|
||||||
|
sa.Column("remote_url", sa.Text, nullable=True),
|
||||||
|
sa.Column("description", sa.Text, nullable=True),
|
||||||
|
sa.Column("status", sa.String(20), nullable=False, server_default="active"),
|
||||||
|
sa.Column("git_fingerprint", sa.String(40), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_managed_repos_domain_id", "managed_repos", ["domain_id"])
|
||||||
|
op.create_index("ix_managed_repos_git_fingerprint", "managed_repos", ["git_fingerprint"])
|
||||||
|
op.create_index("ix_managed_repos_slug", "managed_repos", ["slug"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"agent_messages",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("from_agent", sa.String(100), nullable=False),
|
||||||
|
sa.Column("to_agent", sa.String(100), nullable=False),
|
||||||
|
sa.Column("subject", sa.String(500), nullable=False),
|
||||||
|
sa.Column("body", sa.Text, nullable=False),
|
||||||
|
sa.Column("thread_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("agent_messages.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_agent_messages_created_at", "agent_messages", ["created_at"])
|
||||||
|
op.create_index("ix_agent_messages_thread_id", "agent_messages", ["thread_id"])
|
||||||
|
op.create_index("ix_agent_messages_to_agent_read_at", "agent_messages", ["to_agent", "read_at"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"capability_catalog",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("domain_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("domains.id", ondelete="RESTRICT"), nullable=False),
|
||||||
|
sa.Column("repo_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("capability_type", sa.String(50), nullable=False),
|
||||||
|
sa.Column("title", sa.String(255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text, nullable=True),
|
||||||
|
sa.Column("keywords", postgresql.ARRAY(sa.String()), nullable=False, server_default="{}"),
|
||||||
|
sa.Column("status", sa.String(20), nullable=False, server_default="active"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.UniqueConstraint("domain_id", "capability_type", "title", name="uq_catalog_domain_type_title"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_capability_catalog_domain_id", "capability_catalog", ["domain_id"])
|
||||||
|
op.create_index("ix_capability_catalog_repo_id", "capability_catalog", ["repo_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"capability_requests",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("title", sa.String(500), nullable=False),
|
||||||
|
sa.Column("description", sa.Text, nullable=True),
|
||||||
|
sa.Column("capability_type", sa.String(50), nullable=False),
|
||||||
|
sa.Column("priority", sa.String(20), nullable=False, server_default="medium"),
|
||||||
|
sa.Column("status", sa.String(20), nullable=False, server_default="requested"),
|
||||||
|
sa.Column("requesting_domain_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("domains.id", ondelete="RESTRICT"), nullable=False),
|
||||||
|
sa.Column("requesting_agent", sa.String(100), nullable=False),
|
||||||
|
sa.Column("request_context", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column("fulfilling_domain_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("domains.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("fulfilling_agent", sa.String(100), nullable=True),
|
||||||
|
sa.Column("fulfillment_context", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column("catalog_entry_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("capability_catalog.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("resolution_note", sa.Text, nullable=True),
|
||||||
|
sa.Column("routing_note", sa.Text, nullable=True),
|
||||||
|
sa.Column("dispute_reason", sa.Text, nullable=True),
|
||||||
|
sa.Column("disputed_by", sa.String(100), nullable=True),
|
||||||
|
sa.Column("dispute_suggested_domain", sa.String(100), nullable=True),
|
||||||
|
sa.Column("disputed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("accepted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_capability_requests_fulfilling_domain_id", "capability_requests", ["fulfilling_domain_id"])
|
||||||
|
op.create_index("ix_capability_requests_requesting_domain_id", "capability_requests", ["requesting_domain_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"progress_events",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("event_type", sa.String(50), nullable=False),
|
||||||
|
sa.Column("summary", sa.Text, nullable=False),
|
||||||
|
sa.Column("detail", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column("subject_refs", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column("author", sa.String(100), nullable=True),
|
||||||
|
sa.Column("session_id", sa.String(100), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_progress_events_created_at", "progress_events", ["created_at"])
|
||||||
|
op.create_index("ix_progress_events_event_type", "progress_events", ["event_type"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"tpsc_catalog",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("slug", sa.String(100), nullable=False, unique=True),
|
||||||
|
sa.Column("name", sa.String(200), nullable=False),
|
||||||
|
sa.Column("provider", sa.String(200), nullable=True),
|
||||||
|
sa.Column("category", sa.String(100), nullable=True),
|
||||||
|
sa.Column("website_url", sa.Text, nullable=True),
|
||||||
|
sa.Column("pricing_model", sa.String(20), nullable=False, server_default="unknown"),
|
||||||
|
sa.Column("gdpr_maturity", sa.String(20), nullable=False, server_default="unknown"),
|
||||||
|
sa.Column("gdpr_notes", sa.Text, nullable=True),
|
||||||
|
sa.Column("dpa_available", sa.Boolean, nullable=False, server_default="false"),
|
||||||
|
sa.Column("tos_url", sa.Text, nullable=True),
|
||||||
|
sa.Column("privacy_policy_url", sa.Text, nullable=True),
|
||||||
|
sa.Column("data_processing_regions", postgresql.JSON, nullable=True),
|
||||||
|
sa.Column("data_retention_notes", sa.Text, nullable=True),
|
||||||
|
sa.Column("status", sa.String(20), nullable=False, server_default="active"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_tpsc_catalog_gdpr_maturity", "tpsc_catalog", ["gdpr_maturity"])
|
||||||
|
op.create_index("ix_tpsc_catalog_slug", "tpsc_catalog", ["slug"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"tpsc_snapshots",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("repo_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("snapshot_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("source_file", sa.String(200), nullable=True),
|
||||||
|
sa.Column("entry_count", sa.Integer, nullable=False, server_default="0"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_tpsc_snapshots_repo_id", "tpsc_snapshots", ["repo_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"tpsc_entries",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("snapshot_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tpsc_snapshots.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("catalog_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tpsc_catalog.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("service_slug", sa.String(100), nullable=False),
|
||||||
|
sa.Column("purpose", sa.Text, nullable=True),
|
||||||
|
sa.Column("auth_type", sa.String(50), nullable=True),
|
||||||
|
sa.Column("endpoint_override", sa.Text, nullable=True),
|
||||||
|
sa.Column("notes", sa.Text, nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_tpsc_entries_service_slug", "tpsc_entries", ["service_slug"])
|
||||||
|
op.create_index("ix_tpsc_entries_snapshot_id", "tpsc_entries", ["snapshot_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_tpsc_entries_snapshot_id", table_name="tpsc_entries")
|
||||||
|
op.drop_index("ix_tpsc_entries_service_slug", table_name="tpsc_entries")
|
||||||
|
op.drop_table("tpsc_entries")
|
||||||
|
op.drop_index("ix_tpsc_snapshots_repo_id", table_name="tpsc_snapshots")
|
||||||
|
op.drop_table("tpsc_snapshots")
|
||||||
|
op.drop_index("ix_tpsc_catalog_slug", table_name="tpsc_catalog")
|
||||||
|
op.drop_index("ix_tpsc_catalog_gdpr_maturity", table_name="tpsc_catalog")
|
||||||
|
op.drop_table("tpsc_catalog")
|
||||||
|
op.drop_index("ix_progress_events_event_type", table_name="progress_events")
|
||||||
|
op.drop_index("ix_progress_events_created_at", table_name="progress_events")
|
||||||
|
op.drop_table("progress_events")
|
||||||
|
op.drop_index("ix_capability_requests_requesting_domain_id", table_name="capability_requests")
|
||||||
|
op.drop_index("ix_capability_requests_fulfilling_domain_id", table_name="capability_requests")
|
||||||
|
op.drop_table("capability_requests")
|
||||||
|
op.drop_index("ix_capability_catalog_repo_id", table_name="capability_catalog")
|
||||||
|
op.drop_index("ix_capability_catalog_domain_id", table_name="capability_catalog")
|
||||||
|
op.drop_table("capability_catalog")
|
||||||
|
op.drop_index("ix_agent_messages_to_agent_read_at", table_name="agent_messages")
|
||||||
|
op.drop_index("ix_agent_messages_thread_id", table_name="agent_messages")
|
||||||
|
op.drop_index("ix_agent_messages_created_at", table_name="agent_messages")
|
||||||
|
op.drop_table("agent_messages")
|
||||||
|
op.drop_index("ix_managed_repos_slug", table_name="managed_repos")
|
||||||
|
op.drop_index("ix_managed_repos_git_fingerprint", table_name="managed_repos")
|
||||||
|
op.drop_index("ix_managed_repos_domain_id", table_name="managed_repos")
|
||||||
|
op.drop_table("managed_repos")
|
||||||
|
op.drop_index("ix_domains_slug", table_name="domains")
|
||||||
|
op.drop_table("domains")
|
||||||
1
hub_core/migrations/versions/__init__.py
Normal file
1
hub_core/migrations/versions/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""hub-core Alembic version templates."""
|
||||||
23
hub_core/models/__init__.py
Normal file
23
hub_core/models/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from hub_core.models.agent_message import AgentMessage
|
||||||
|
from hub_core.models.base import Base, TimestampMixin, new_uuid
|
||||||
|
from hub_core.models.capability_catalog import CapabilityCatalog
|
||||||
|
from hub_core.models.capability_request import CapabilityRequest
|
||||||
|
from hub_core.models.domain import Domain
|
||||||
|
from hub_core.models.managed_repo import ManagedRepo
|
||||||
|
from hub_core.models.progress_event import ProgressEvent
|
||||||
|
from hub_core.models.tpsc import TPSCCatalog, TPSCEntry, TPSCSnapshot
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AgentMessage",
|
||||||
|
"Base",
|
||||||
|
"CapabilityCatalog",
|
||||||
|
"CapabilityRequest",
|
||||||
|
"Domain",
|
||||||
|
"ManagedRepo",
|
||||||
|
"ProgressEvent",
|
||||||
|
"TPSCCatalog",
|
||||||
|
"TPSCEntry",
|
||||||
|
"TPSCSnapshot",
|
||||||
|
"TimestampMixin",
|
||||||
|
"new_uuid",
|
||||||
|
]
|
||||||
44
hub_core/models/agent_message.py
Normal file
44
hub_core/models/agent_message.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, Text, text
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from hub_core.models.base import Base, new_uuid
|
||||||
|
|
||||||
|
|
||||||
|
class AgentMessage(Base):
|
||||||
|
__tablename__ = "agent_messages"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||||
|
)
|
||||||
|
from_agent: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
to_agent: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||||
|
subject: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
thread_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("agent_messages.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
read_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
archived_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
thread_root: Mapped["AgentMessage | None"] = relationship(
|
||||||
|
"AgentMessage",
|
||||||
|
remote_side="AgentMessage.id",
|
||||||
|
foreign_keys=[thread_id],
|
||||||
|
lazy="select",
|
||||||
|
)
|
||||||
25
hub_core/models/base.py
Normal file
25
hub_core/models/base.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, func
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin:
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def new_uuid() -> uuid.UUID:
|
||||||
|
return uuid.uuid4()
|
||||||
50
hub_core/models/capability_catalog.py
Normal file
50
hub_core/models/capability_catalog.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import ARRAY, ForeignKey, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from hub_core.models.base import Base, TimestampMixin, new_uuid
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityCatalog(Base, TimestampMixin):
|
||||||
|
__tablename__ = "capability_catalog"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("domain_id", "capability_type", "title", name="uq_catalog_domain_type_title"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||||
|
)
|
||||||
|
domain_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("domains.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("managed_repos.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
capability_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
keywords: Mapped[list[str]] = mapped_column(
|
||||||
|
ARRAY(String), nullable=False, server_default="{}"
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="active", server_default="active"
|
||||||
|
)
|
||||||
|
|
||||||
|
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
|
||||||
|
repo: Mapped["ManagedRepo | None"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
||||||
|
|
||||||
|
@property
|
||||||
|
def domain_slug(self) -> str:
|
||||||
|
return self.domain.slug if self.domain is not None else ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def repo_slug(self) -> str | None:
|
||||||
|
return self.repo.slug if self.repo is not None else None
|
||||||
76
hub_core/models/capability_request.py
Normal file
76
hub_core/models/capability_request.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from hub_core.models.base import Base, TimestampMixin, new_uuid
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityRequest(Base, TimestampMixin):
|
||||||
|
__tablename__ = "capability_requests"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||||
|
)
|
||||||
|
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
capability_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
priority: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="medium", server_default="medium"
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="requested", server_default="requested"
|
||||||
|
)
|
||||||
|
|
||||||
|
requesting_domain_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("domains.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
requesting_agent: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
request_context: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
|
||||||
|
fulfilling_domain_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("domains.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
fulfilling_agent: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
fulfillment_context: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
|
||||||
|
catalog_entry_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("capability_catalog.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
routing_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
dispute_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
disputed_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
dispute_suggested_domain: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
disputed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
requesting_domain: Mapped["Domain"] = relationship( # noqa: F821
|
||||||
|
"Domain", foreign_keys=[requesting_domain_id], lazy="selectin"
|
||||||
|
)
|
||||||
|
fulfilling_domain: Mapped["Domain | None"] = relationship( # noqa: F821
|
||||||
|
"Domain", foreign_keys=[fulfilling_domain_id], lazy="selectin"
|
||||||
|
)
|
||||||
|
catalog_entry: Mapped["CapabilityCatalog | None"] = relationship( # noqa: F821
|
||||||
|
"CapabilityCatalog", lazy="selectin"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def requesting_domain_slug(self) -> str:
|
||||||
|
return self.requesting_domain.slug if self.requesting_domain else ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fulfilling_domain_slug(self) -> str | None:
|
||||||
|
return self.fulfilling_domain.slug if self.fulfilling_domain else None
|
||||||
23
hub_core/models/domain.py
Normal file
23
hub_core/models/domain.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from hub_core.models.base import Base, TimestampMixin, new_uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Domain(Base, TimestampMixin):
|
||||||
|
__tablename__ = "domains"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||||
|
)
|
||||||
|
slug: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
|
||||||
|
|
||||||
|
repos: Mapped[list["ManagedRepo"]] = relationship( # noqa: F821
|
||||||
|
"ManagedRepo", back_populates="domain", lazy="selectin"
|
||||||
|
)
|
||||||
37
hub_core/models/managed_repo.py
Normal file
37
hub_core/models/managed_repo.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from hub_core.models.base import Base, TimestampMixin, new_uuid
|
||||||
|
|
||||||
|
|
||||||
|
class ManagedRepo(Base, TimestampMixin):
|
||||||
|
__tablename__ = "managed_repos"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||||
|
)
|
||||||
|
domain_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("domains.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
local_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
host_paths: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
|
||||||
|
remote_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
|
||||||
|
git_fingerprint: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
|
||||||
|
|
||||||
|
domain: Mapped["Domain"] = relationship( # noqa: F821
|
||||||
|
"Domain", back_populates="repos", lazy="selectin"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def domain_slug(self) -> str:
|
||||||
|
return self.domain.slug if self.domain is not None else ""
|
||||||
27
hub_core/models/progress_event.py
Normal file
27
hub_core/models/progress_event.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from hub_core.models.base import Base, new_uuid
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressEvent(Base):
|
||||||
|
"""Generic append-only event log for hub activity."""
|
||||||
|
|
||||||
|
__tablename__ = "progress_events"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||||
|
)
|
||||||
|
event_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
summary: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
detail: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
subject_refs: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
author: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
session_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
|
||||||
|
)
|
||||||
78
hub_core/models/tpsc.py
Normal file
78
hub_core/models/tpsc.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSON, UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from hub_core.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class TPSCCatalog(Base):
|
||||||
|
__tablename__ = "tpsc_catalog"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
slug: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
provider: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||||
|
category: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
website_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
pricing_model: Mapped[str] = mapped_column(String(20), nullable=False, server_default="unknown")
|
||||||
|
gdpr_maturity: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, server_default="unknown", index=True
|
||||||
|
)
|
||||||
|
gdpr_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
dpa_available: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
||||||
|
tos_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
data_processing_regions: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||||
|
data_retention_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="active")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
entries: Mapped[list["TPSCEntry"]] = relationship("TPSCEntry", back_populates="catalog_entry")
|
||||||
|
|
||||||
|
|
||||||
|
class TPSCSnapshot(Base):
|
||||||
|
__tablename__ = "tpsc_snapshots"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("managed_repos.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
snapshot_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
source_file: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||||
|
entry_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0")
|
||||||
|
|
||||||
|
entries: Mapped[list["TPSCEntry"]] = relationship(
|
||||||
|
"TPSCEntry", back_populates="snapshot", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TPSCEntry(Base):
|
||||||
|
__tablename__ = "tpsc_entries"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
snapshot_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("tpsc_snapshots.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
catalog_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("tpsc_catalog.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
service_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||||
|
purpose: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
auth_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
endpoint_override: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
snapshot: Mapped["TPSCSnapshot"] = relationship("TPSCSnapshot", back_populates="entries")
|
||||||
|
catalog_entry: Mapped["TPSCCatalog | None"] = relationship("TPSCCatalog", back_populates="entries")
|
||||||
23
hub_core/routers/__init__.py
Normal file
23
hub_core/routers/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from hub_core.routers.capabilities import (
|
||||||
|
create_capabilities_router,
|
||||||
|
create_capability_catalog_router,
|
||||||
|
create_capability_request_read_router,
|
||||||
|
)
|
||||||
|
from hub_core.routers.domains import create_domains_router
|
||||||
|
from hub_core.routers.messages import create_messages_router
|
||||||
|
from hub_core.routers.policy import create_policy_router
|
||||||
|
from hub_core.routers.progress import create_progress_router
|
||||||
|
from hub_core.routers.repos import create_repos_router
|
||||||
|
from hub_core.routers.tpsc import create_tpsc_router
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"create_capabilities_router",
|
||||||
|
"create_capability_catalog_router",
|
||||||
|
"create_capability_request_read_router",
|
||||||
|
"create_domains_router",
|
||||||
|
"create_messages_router",
|
||||||
|
"create_policy_router",
|
||||||
|
"create_progress_router",
|
||||||
|
"create_repos_router",
|
||||||
|
"create_tpsc_router",
|
||||||
|
]
|
||||||
427
hub_core/routers/capabilities.py
Normal file
427
hub_core/routers/capabilities.py
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
import uuid
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from hub_core.models.capability_catalog import CapabilityCatalog
|
||||||
|
from hub_core.models.capability_request import CapabilityRequest
|
||||||
|
from hub_core.models.domain import Domain
|
||||||
|
from hub_core.models.managed_repo import ManagedRepo
|
||||||
|
from hub_core.schemas.capability import (
|
||||||
|
CapabilityRequestAccept,
|
||||||
|
CapabilityRequestCreate,
|
||||||
|
CapabilityRequestDispute,
|
||||||
|
CapabilityRequestPatch,
|
||||||
|
CapabilityRequestRead,
|
||||||
|
CapabilityRequestStatusPatch,
|
||||||
|
CatalogCreate,
|
||||||
|
CatalogPatch,
|
||||||
|
CatalogRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_capability_catalog_router(
|
||||||
|
get_session: Callable[..., AsyncSession],
|
||||||
|
*,
|
||||||
|
domain_model: type[Domain] = Domain,
|
||||||
|
repo_model: type[ManagedRepo] = ManagedRepo,
|
||||||
|
catalog_model: type[CapabilityCatalog] = CapabilityCatalog,
|
||||||
|
catalog_create_schema: type[CatalogCreate] = CatalogCreate,
|
||||||
|
catalog_patch_schema: type[CatalogPatch] = CatalogPatch,
|
||||||
|
catalog_read_schema: type[CatalogRead] = CatalogRead,
|
||||||
|
) -> APIRouter:
|
||||||
|
router = APIRouter(tags=["capability-requests"])
|
||||||
|
list_response_model = list[catalog_read_schema]
|
||||||
|
|
||||||
|
@router.post("/capability-catalog/", response_model=catalog_read_schema, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_catalog_entry(
|
||||||
|
body: catalog_create_schema,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> Any:
|
||||||
|
domain = await _resolve_domain(body.domain, session, domain_model)
|
||||||
|
repo_id = None
|
||||||
|
if body.repo_slug:
|
||||||
|
repo = await _resolve_repo(body.repo_slug, session, repo_model)
|
||||||
|
repo_id = repo.id
|
||||||
|
entry = catalog_model(
|
||||||
|
domain_id=domain.id,
|
||||||
|
repo_id=repo_id,
|
||||||
|
capability_type=body.capability_type,
|
||||||
|
title=body.title,
|
||||||
|
description=body.description,
|
||||||
|
keywords=body.keywords,
|
||||||
|
)
|
||||||
|
session.add(entry)
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=(
|
||||||
|
f"Catalog entry '{body.title}' for type '{body.capability_type}' "
|
||||||
|
f"already exists in domain '{body.domain}'"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await session.refresh(entry)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
@router.get("/capability-catalog/", response_model=list_response_model)
|
||||||
|
async def list_catalog(
|
||||||
|
domain: str | None = Query(None),
|
||||||
|
capability_type: str | None = Query(None),
|
||||||
|
status_filter: str | None = Query(None, alias="status"),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[Any]:
|
||||||
|
q = select(catalog_model).order_by(catalog_model.created_at.desc())
|
||||||
|
if domain:
|
||||||
|
domain_obj = await _resolve_domain(domain, session, domain_model)
|
||||||
|
q = q.where(catalog_model.domain_id == domain_obj.id)
|
||||||
|
if capability_type:
|
||||||
|
q = q.where(catalog_model.capability_type == capability_type)
|
||||||
|
if status_filter and status_filter != "all":
|
||||||
|
q = q.where(catalog_model.status == status_filter)
|
||||||
|
elif not status_filter:
|
||||||
|
q = q.where(catalog_model.status == "active")
|
||||||
|
result = await session.execute(q)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@router.patch("/capability-catalog/{entry_id}", response_model=catalog_read_schema)
|
||||||
|
async def patch_catalog_entry(
|
||||||
|
entry_id: uuid.UUID,
|
||||||
|
body: catalog_patch_schema,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> Any:
|
||||||
|
entry = await session.get(catalog_model, entry_id)
|
||||||
|
if entry is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Catalog entry '{entry_id}' not found")
|
||||||
|
if body.repo_slug is not None:
|
||||||
|
repo = await _resolve_repo(body.repo_slug, session, repo_model)
|
||||||
|
entry.repo_id = repo.id
|
||||||
|
if body.description is not None:
|
||||||
|
entry.description = body.description
|
||||||
|
if body.keywords is not None:
|
||||||
|
entry.keywords = body.keywords
|
||||||
|
if body.status is not None:
|
||||||
|
entry.status = body.status
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(entry)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
def create_capability_request_read_router(
|
||||||
|
get_session: Callable[..., AsyncSession],
|
||||||
|
*,
|
||||||
|
domain_model: type[Domain] = Domain,
|
||||||
|
request_model: type[CapabilityRequest] = CapabilityRequest,
|
||||||
|
request_read_schema: type[CapabilityRequestRead] = CapabilityRequestRead,
|
||||||
|
) -> APIRouter:
|
||||||
|
router = APIRouter(tags=["capability-requests"])
|
||||||
|
list_response_model = list[request_read_schema]
|
||||||
|
|
||||||
|
@router.get("/capability-requests/", response_model=list_response_model)
|
||||||
|
async def list_requests(
|
||||||
|
domain: str | None = Query(
|
||||||
|
None,
|
||||||
|
description="Filter by requesting or fulfilling domain slug",
|
||||||
|
),
|
||||||
|
status_filter: str | None = Query(None, alias="status"),
|
||||||
|
capability_type: str | None = Query(None),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[Any]:
|
||||||
|
q = select(request_model).order_by(request_model.created_at.desc())
|
||||||
|
if domain:
|
||||||
|
domain_obj = await _resolve_domain(domain, session, domain_model)
|
||||||
|
q = q.where(
|
||||||
|
(request_model.requesting_domain_id == domain_obj.id)
|
||||||
|
| (request_model.fulfilling_domain_id == domain_obj.id)
|
||||||
|
)
|
||||||
|
if status_filter:
|
||||||
|
q = q.where(request_model.status == status_filter)
|
||||||
|
if capability_type:
|
||||||
|
q = q.where(request_model.capability_type == capability_type)
|
||||||
|
result = await session.execute(q)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@router.get("/capability-requests/{request_id}", response_model=request_read_schema)
|
||||||
|
async def get_request(
|
||||||
|
request_id: uuid.UUID,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> Any:
|
||||||
|
req = await session.get(request_model, request_id)
|
||||||
|
if req is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Capability request '{request_id}' not found")
|
||||||
|
return req
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
def create_capabilities_router(get_session: Callable[..., AsyncSession]) -> APIRouter:
|
||||||
|
router = APIRouter(tags=["capability-requests"])
|
||||||
|
|
||||||
|
@router.post("/capability-catalog/", response_model=CatalogRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_catalog_entry(
|
||||||
|
body: CatalogCreate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> CapabilityCatalog:
|
||||||
|
domain = await _resolve_domain(body.domain, session, Domain)
|
||||||
|
repo_id = None
|
||||||
|
if body.repo_slug:
|
||||||
|
repo = await _resolve_repo(body.repo_slug, session, ManagedRepo)
|
||||||
|
repo_id = repo.id
|
||||||
|
entry = CapabilityCatalog(
|
||||||
|
domain_id=domain.id,
|
||||||
|
repo_id=repo_id,
|
||||||
|
capability_type=body.capability_type,
|
||||||
|
title=body.title,
|
||||||
|
description=body.description,
|
||||||
|
keywords=body.keywords,
|
||||||
|
)
|
||||||
|
session.add(entry)
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=(
|
||||||
|
f"Catalog entry '{body.title}' for type '{body.capability_type}' "
|
||||||
|
f"already exists in domain '{body.domain}'"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await session.refresh(entry)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
@router.get("/capability-catalog/", response_model=list[CatalogRead])
|
||||||
|
async def list_catalog(
|
||||||
|
domain: str | None = Query(None),
|
||||||
|
capability_type: str | None = Query(None),
|
||||||
|
status_filter: str | None = Query(None, alias="status"),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[CapabilityCatalog]:
|
||||||
|
q = select(CapabilityCatalog).order_by(CapabilityCatalog.created_at.desc())
|
||||||
|
if domain:
|
||||||
|
domain_obj = await _resolve_domain(domain, session, Domain)
|
||||||
|
q = q.where(CapabilityCatalog.domain_id == domain_obj.id)
|
||||||
|
if capability_type:
|
||||||
|
q = q.where(CapabilityCatalog.capability_type == capability_type)
|
||||||
|
if status_filter and status_filter != "all":
|
||||||
|
q = q.where(CapabilityCatalog.status == status_filter)
|
||||||
|
elif not status_filter:
|
||||||
|
q = q.where(CapabilityCatalog.status == "active")
|
||||||
|
result = await session.execute(q)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@router.patch("/capability-catalog/{entry_id}", response_model=CatalogRead)
|
||||||
|
async def patch_catalog_entry(
|
||||||
|
entry_id: uuid.UUID,
|
||||||
|
body: CatalogPatch,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> CapabilityCatalog:
|
||||||
|
entry = await session.get(CapabilityCatalog, entry_id)
|
||||||
|
if entry is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Catalog entry '{entry_id}' not found")
|
||||||
|
if body.repo_slug is not None:
|
||||||
|
repo = await _resolve_repo(body.repo_slug, session, ManagedRepo)
|
||||||
|
entry.repo_id = repo.id
|
||||||
|
if body.description is not None:
|
||||||
|
entry.description = body.description
|
||||||
|
if body.keywords is not None:
|
||||||
|
entry.keywords = body.keywords
|
||||||
|
if body.status is not None:
|
||||||
|
entry.status = body.status
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(entry)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
@router.post("/capability-requests/", response_model=CapabilityRequestRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_request(
|
||||||
|
body: CapabilityRequestCreate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> CapabilityRequest:
|
||||||
|
requesting_domain = await _resolve_domain(body.requesting_domain, session, Domain)
|
||||||
|
fulfilling_domain_id = None
|
||||||
|
catalog_entry_id = body.catalog_entry_id
|
||||||
|
routing_note = None
|
||||||
|
if catalog_entry_id:
|
||||||
|
catalog_entry = await _resolve_catalog_entry(catalog_entry_id, session)
|
||||||
|
fulfilling_domain_id = catalog_entry.domain_id
|
||||||
|
routing_note = "Routed by explicit catalog entry."
|
||||||
|
else:
|
||||||
|
catalog_entry = await _find_catalog_route(body.capability_type, session)
|
||||||
|
if catalog_entry:
|
||||||
|
catalog_entry_id = catalog_entry.id
|
||||||
|
fulfilling_domain_id = catalog_entry.domain_id
|
||||||
|
routing_note = "Routed by first active catalog match for capability_type."
|
||||||
|
|
||||||
|
req = CapabilityRequest(
|
||||||
|
title=body.title,
|
||||||
|
description=body.description,
|
||||||
|
capability_type=body.capability_type,
|
||||||
|
priority=body.priority,
|
||||||
|
requesting_domain_id=requesting_domain.id,
|
||||||
|
requesting_agent=body.requesting_agent,
|
||||||
|
request_context=body.request_context,
|
||||||
|
fulfilling_domain_id=fulfilling_domain_id,
|
||||||
|
catalog_entry_id=catalog_entry_id,
|
||||||
|
routing_note=routing_note,
|
||||||
|
)
|
||||||
|
session.add(req)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(req)
|
||||||
|
return req
|
||||||
|
|
||||||
|
@router.get("/capability-requests/", response_model=list[CapabilityRequestRead])
|
||||||
|
async def list_requests(
|
||||||
|
domain: str | None = Query(None, description="Filter by requesting or fulfilling domain slug"),
|
||||||
|
status_filter: str | None = Query(None, alias="status"),
|
||||||
|
capability_type: str | None = Query(None),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[CapabilityRequest]:
|
||||||
|
q = select(CapabilityRequest).order_by(CapabilityRequest.created_at.desc())
|
||||||
|
if domain:
|
||||||
|
domain_obj = await _resolve_domain(domain, session, Domain)
|
||||||
|
q = q.where(
|
||||||
|
(CapabilityRequest.requesting_domain_id == domain_obj.id)
|
||||||
|
| (CapabilityRequest.fulfilling_domain_id == domain_obj.id)
|
||||||
|
)
|
||||||
|
if status_filter:
|
||||||
|
q = q.where(CapabilityRequest.status == status_filter)
|
||||||
|
if capability_type:
|
||||||
|
q = q.where(CapabilityRequest.capability_type == capability_type)
|
||||||
|
result = await session.execute(q)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@router.get("/capability-requests/{request_id}", response_model=CapabilityRequestRead)
|
||||||
|
async def get_request(
|
||||||
|
request_id: uuid.UUID,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> CapabilityRequest:
|
||||||
|
return await _get_request_or_404(request_id, session)
|
||||||
|
|
||||||
|
@router.post("/capability-requests/{request_id}/accept", response_model=CapabilityRequestRead)
|
||||||
|
async def accept_request(
|
||||||
|
request_id: uuid.UUID,
|
||||||
|
body: CapabilityRequestAccept,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> CapabilityRequest:
|
||||||
|
req = await _get_request_or_404(request_id, session)
|
||||||
|
req.status = "accepted"
|
||||||
|
req.fulfilling_agent = body.fulfilling_agent
|
||||||
|
req.fulfillment_context = body.fulfillment_context
|
||||||
|
req.accepted_at = datetime.now(tz=timezone.utc)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(req)
|
||||||
|
return req
|
||||||
|
|
||||||
|
@router.patch("/capability-requests/{request_id}", response_model=CapabilityRequestRead)
|
||||||
|
async def patch_request(
|
||||||
|
request_id: uuid.UUID,
|
||||||
|
body: CapabilityRequestPatch,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> CapabilityRequest:
|
||||||
|
req = await _get_request_or_404(request_id, session)
|
||||||
|
if body.catalog_entry_id is not None:
|
||||||
|
catalog_entry = await _resolve_catalog_entry(body.catalog_entry_id, session)
|
||||||
|
req.catalog_entry_id = catalog_entry.id
|
||||||
|
req.fulfilling_domain_id = catalog_entry.domain_id
|
||||||
|
if body.priority is not None:
|
||||||
|
req.priority = body.priority
|
||||||
|
if body.request_context is not None:
|
||||||
|
req.request_context = body.request_context
|
||||||
|
if body.fulfillment_context is not None:
|
||||||
|
req.fulfillment_context = body.fulfillment_context
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(req)
|
||||||
|
return req
|
||||||
|
|
||||||
|
@router.patch("/capability-requests/{request_id}/status", response_model=CapabilityRequestRead)
|
||||||
|
async def patch_request_status(
|
||||||
|
request_id: uuid.UUID,
|
||||||
|
body: CapabilityRequestStatusPatch,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> CapabilityRequest:
|
||||||
|
req = await _get_request_or_404(request_id, session)
|
||||||
|
req.status = body.status
|
||||||
|
if body.note:
|
||||||
|
req.resolution_note = body.note
|
||||||
|
if body.status == "completed":
|
||||||
|
req.completed_at = datetime.now(tz=timezone.utc)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(req)
|
||||||
|
return req
|
||||||
|
|
||||||
|
@router.post("/capability-requests/{request_id}/dispute", response_model=CapabilityRequestRead)
|
||||||
|
async def dispute_request(
|
||||||
|
request_id: uuid.UUID,
|
||||||
|
body: CapabilityRequestDispute,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> CapabilityRequest:
|
||||||
|
req = await _get_request_or_404(request_id, session)
|
||||||
|
req.status = "routing_disputed"
|
||||||
|
req.dispute_reason = body.reason
|
||||||
|
req.disputed_by = body.disputed_by
|
||||||
|
req.dispute_suggested_domain = body.suggested_domain
|
||||||
|
req.disputed_at = datetime.now(tz=timezone.utc)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(req)
|
||||||
|
return req
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_domain(
|
||||||
|
slug: str,
|
||||||
|
session: AsyncSession,
|
||||||
|
domain_model: type[Domain],
|
||||||
|
) -> Any:
|
||||||
|
result = await session.execute(select(domain_model).where(domain_model.slug == slug))
|
||||||
|
domain = result.scalar_one_or_none()
|
||||||
|
if domain is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Domain '{slug}' not found")
|
||||||
|
return domain
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_repo(
|
||||||
|
slug: str,
|
||||||
|
session: AsyncSession,
|
||||||
|
repo_model: type[ManagedRepo],
|
||||||
|
) -> Any:
|
||||||
|
result = await session.execute(select(repo_model).where(repo_model.slug == slug))
|
||||||
|
repo = result.scalar_one_or_none()
|
||||||
|
if repo is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_catalog_entry(entry_id: uuid.UUID, session: AsyncSession) -> CapabilityCatalog:
|
||||||
|
entry = await session.get(CapabilityCatalog, entry_id)
|
||||||
|
if entry is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Catalog entry '{entry_id}' not found")
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
async def _find_catalog_route(
|
||||||
|
capability_type: str,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> CapabilityCatalog | None:
|
||||||
|
result = await session.execute(
|
||||||
|
select(CapabilityCatalog)
|
||||||
|
.where(CapabilityCatalog.capability_type == capability_type)
|
||||||
|
.where(CapabilityCatalog.status == "active")
|
||||||
|
.order_by(CapabilityCatalog.created_at.desc())
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_request_or_404(request_id: uuid.UUID, session: AsyncSession) -> CapabilityRequest:
|
||||||
|
req = await session.get(CapabilityRequest, request_id)
|
||||||
|
if req is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Capability request '{request_id}' not found")
|
||||||
|
return req
|
||||||
178
hub_core/routers/domains.py
Normal file
178
hub_core/routers/domains.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any, Awaitable
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import noload
|
||||||
|
|
||||||
|
from hub_core.models.domain import Domain
|
||||||
|
from hub_core.models.managed_repo import ManagedRepo
|
||||||
|
from hub_core.schemas.domain import DomainCreate, DomainDetail, DomainRead, DomainRename, DomainUpdate, RepoStub
|
||||||
|
|
||||||
|
|
||||||
|
DomainDetailBuilder = Callable[[Any, AsyncSession], Awaitable[Any]]
|
||||||
|
DomainArchiveValidator = Callable[[Any, AsyncSession], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
|
def create_domains_router(
|
||||||
|
get_session: Callable[..., AsyncSession],
|
||||||
|
*,
|
||||||
|
domain_model: type[Domain] = Domain,
|
||||||
|
repo_model: type[ManagedRepo] = ManagedRepo,
|
||||||
|
domain_create_schema: type[DomainCreate] = DomainCreate,
|
||||||
|
domain_detail_schema: type[DomainDetail] = DomainDetail,
|
||||||
|
domain_read_schema: type[DomainRead] = DomainRead,
|
||||||
|
domain_rename_schema: type[DomainRename] = DomainRename,
|
||||||
|
domain_update_schema: type[DomainUpdate] = DomainUpdate,
|
||||||
|
repo_stub_schema: type[RepoStub] = RepoStub,
|
||||||
|
detail_builder: DomainDetailBuilder | None = None,
|
||||||
|
before_archive: DomainArchiveValidator | None = None,
|
||||||
|
list_noload_fields: tuple[str, ...] = ("repos",),
|
||||||
|
include_update_route: bool = True,
|
||||||
|
) -> APIRouter:
|
||||||
|
router = APIRouter(prefix="/domains", tags=["domains"])
|
||||||
|
list_response_model = list[domain_read_schema]
|
||||||
|
|
||||||
|
@router.get("/", response_model=list_response_model)
|
||||||
|
async def list_domains(
|
||||||
|
response: Response,
|
||||||
|
status_filter: str | None = Query(None, alias="status", description="active | archived | all"),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[Any]:
|
||||||
|
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
|
||||||
|
q = select(domain_model).options(
|
||||||
|
*[
|
||||||
|
noload(getattr(domain_model, field))
|
||||||
|
for field in list_noload_fields
|
||||||
|
if hasattr(domain_model, field)
|
||||||
|
]
|
||||||
|
).order_by(domain_model.name)
|
||||||
|
if status_filter and status_filter != "all":
|
||||||
|
q = q.where(domain_model.status == status_filter)
|
||||||
|
elif status_filter is None:
|
||||||
|
q = q.where(domain_model.status == "active")
|
||||||
|
result = await session.execute(q)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@router.post("/", response_model=domain_read_schema, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_domain(
|
||||||
|
body: domain_create_schema,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> Any:
|
||||||
|
existing = await session.execute(select(domain_model).where(domain_model.slug == body.slug))
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=409, detail=f"Domain slug '{body.slug}' already exists")
|
||||||
|
domain = domain_model(slug=body.slug, name=body.name, description=body.description)
|
||||||
|
session.add(domain)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(domain)
|
||||||
|
return domain
|
||||||
|
|
||||||
|
@router.get("/{slug}", response_model=domain_detail_schema)
|
||||||
|
async def get_domain(
|
||||||
|
slug: str,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> Any:
|
||||||
|
domain = await _get_domain_by_slug(slug, session, domain_model)
|
||||||
|
if detail_builder is not None:
|
||||||
|
return await detail_builder(domain, session)
|
||||||
|
return await _build_default_domain_detail(
|
||||||
|
domain,
|
||||||
|
session,
|
||||||
|
repo_model=repo_model,
|
||||||
|
repo_stub_schema=repo_stub_schema,
|
||||||
|
domain_detail_schema=domain_detail_schema,
|
||||||
|
)
|
||||||
|
|
||||||
|
if include_update_route:
|
||||||
|
@router.patch("/{slug}", response_model=domain_read_schema)
|
||||||
|
async def update_domain(
|
||||||
|
slug: str,
|
||||||
|
body: domain_update_schema,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> Any:
|
||||||
|
domain = await _get_domain_by_slug(slug, session, domain_model)
|
||||||
|
for field, value in body.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(domain, field, value)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(domain)
|
||||||
|
return domain
|
||||||
|
|
||||||
|
@router.patch("/{slug}/rename", response_model=domain_read_schema)
|
||||||
|
async def rename_domain(
|
||||||
|
slug: str,
|
||||||
|
body: domain_rename_schema,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> Any:
|
||||||
|
domain = await _get_domain_by_slug(slug, session, domain_model)
|
||||||
|
if body.new_slug != slug:
|
||||||
|
conflict = await session.execute(select(domain_model).where(domain_model.slug == body.new_slug))
|
||||||
|
if conflict.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=409, detail=f"Slug '{body.new_slug}' already taken")
|
||||||
|
domain.slug = body.new_slug
|
||||||
|
domain.name = body.new_name
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(domain)
|
||||||
|
return domain
|
||||||
|
|
||||||
|
@router.patch("/{slug}/archive", response_model=domain_read_schema)
|
||||||
|
async def archive_domain(
|
||||||
|
slug: str,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> Any:
|
||||||
|
domain = await _get_domain_by_slug(slug, session, domain_model)
|
||||||
|
if before_archive is not None:
|
||||||
|
await before_archive(domain, session)
|
||||||
|
domain.status = "archived"
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(domain)
|
||||||
|
return domain
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_domain_by_slug(
|
||||||
|
slug: str,
|
||||||
|
session: AsyncSession,
|
||||||
|
domain_model: type[Domain],
|
||||||
|
) -> Any:
|
||||||
|
result = await session.execute(select(domain_model).where(domain_model.slug == slug))
|
||||||
|
domain = result.scalar_one_or_none()
|
||||||
|
if domain is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Domain '{slug}' not found")
|
||||||
|
return domain
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_default_domain_detail(
|
||||||
|
domain: Any,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
repo_model: type[ManagedRepo],
|
||||||
|
repo_stub_schema: type[RepoStub],
|
||||||
|
domain_detail_schema: type[DomainDetail],
|
||||||
|
) -> Any:
|
||||||
|
repo_count_row = await session.execute(
|
||||||
|
select(func.count()).select_from(repo_model)
|
||||||
|
.where(repo_model.domain_id == domain.id)
|
||||||
|
.where(repo_model.status == "active")
|
||||||
|
)
|
||||||
|
repos_row = await session.execute(
|
||||||
|
select(repo_model)
|
||||||
|
.where(repo_model.domain_id == domain.id)
|
||||||
|
.where(repo_model.status == "active")
|
||||||
|
.order_by(repo_model.name)
|
||||||
|
)
|
||||||
|
repos = list(repos_row.scalars().all())
|
||||||
|
|
||||||
|
return domain_detail_schema(
|
||||||
|
id=domain.id,
|
||||||
|
slug=domain.slug,
|
||||||
|
name=domain.name,
|
||||||
|
description=domain.description,
|
||||||
|
status=domain.status,
|
||||||
|
created_at=domain.created_at,
|
||||||
|
updated_at=domain.updated_at,
|
||||||
|
repos=[repo_stub_schema.model_validate(repo) for repo in repos],
|
||||||
|
extension_counts={"repos": repo_count_row.scalar_one()},
|
||||||
|
)
|
||||||
121
hub_core/routers/messages.py
Normal file
121
hub_core/routers/messages.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import uuid
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from hub_core.models.agent_message import AgentMessage
|
||||||
|
from hub_core.schemas.agent_message import MessageCreate, MessageRead, MessageReply
|
||||||
|
|
||||||
|
|
||||||
|
def create_messages_router(
|
||||||
|
get_session: Callable[..., AsyncSession],
|
||||||
|
*,
|
||||||
|
message_model: type[AgentMessage] = AgentMessage,
|
||||||
|
) -> APIRouter:
|
||||||
|
router = APIRouter(prefix="/messages", tags=["messages"])
|
||||||
|
|
||||||
|
async def _get_message(message_id: uuid.UUID, session: AsyncSession) -> Any:
|
||||||
|
msg = await session.get(message_model, message_id)
|
||||||
|
if msg is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Message {message_id} not found")
|
||||||
|
return msg
|
||||||
|
|
||||||
|
@router.post("/", response_model=MessageRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def send_message(
|
||||||
|
body: MessageCreate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> Any:
|
||||||
|
if body.thread_id:
|
||||||
|
root = await session.get(message_model, body.thread_id)
|
||||||
|
if root is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Thread root {body.thread_id} not found")
|
||||||
|
msg = message_model(**body.model_dump())
|
||||||
|
session.add(msg)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(msg)
|
||||||
|
return msg
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[MessageRead])
|
||||||
|
async def list_messages(
|
||||||
|
to_agent: str | None = None,
|
||||||
|
from_agent: str | None = None,
|
||||||
|
unread_only: bool = False,
|
||||||
|
limit: int = 50,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[Any]:
|
||||||
|
q = select(message_model).where(message_model.archived_at.is_(None))
|
||||||
|
if to_agent:
|
||||||
|
q = q.where(
|
||||||
|
(message_model.to_agent == to_agent) | (message_model.to_agent == "broadcast")
|
||||||
|
)
|
||||||
|
if from_agent:
|
||||||
|
q = q.where(message_model.from_agent == from_agent)
|
||||||
|
if unread_only:
|
||||||
|
q = q.where(message_model.read_at.is_(None))
|
||||||
|
q = q.order_by(message_model.created_at.desc()).limit(limit)
|
||||||
|
result = await session.execute(q)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@router.get("/thread/{thread_id}", response_model=list[MessageRead])
|
||||||
|
async def get_thread(
|
||||||
|
thread_id: uuid.UUID,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[Any]:
|
||||||
|
q = select(message_model).where(
|
||||||
|
(message_model.id == thread_id) | (message_model.thread_id == thread_id)
|
||||||
|
).order_by(message_model.created_at)
|
||||||
|
result = await session.execute(q)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@router.patch("/{message_id}/read", response_model=MessageRead)
|
||||||
|
async def mark_read(
|
||||||
|
message_id: uuid.UUID,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> Any:
|
||||||
|
msg = await _get_message(message_id, session)
|
||||||
|
if msg.read_at is None:
|
||||||
|
msg.read_at = datetime.now(timezone.utc)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(msg)
|
||||||
|
return msg
|
||||||
|
|
||||||
|
@router.patch("/{message_id}/archive", response_model=MessageRead)
|
||||||
|
async def archive_message(
|
||||||
|
message_id: uuid.UUID,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> Any:
|
||||||
|
msg = await _get_message(message_id, session)
|
||||||
|
msg.archived_at = datetime.now(timezone.utc)
|
||||||
|
if msg.read_at is None:
|
||||||
|
msg.read_at = msg.archived_at
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(msg)
|
||||||
|
return msg
|
||||||
|
|
||||||
|
@router.post("/{message_id}/reply", response_model=MessageRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def reply_to_message(
|
||||||
|
message_id: uuid.UUID,
|
||||||
|
body: MessageReply,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> Any:
|
||||||
|
original = await _get_message(message_id, session)
|
||||||
|
if original.read_at is None:
|
||||||
|
original.read_at = datetime.now(timezone.utc)
|
||||||
|
thread_root = original.thread_id or original.id
|
||||||
|
reply = message_model(
|
||||||
|
from_agent=body.from_agent,
|
||||||
|
to_agent=original.from_agent,
|
||||||
|
subject=f"Re: {original.subject}",
|
||||||
|
body=body.body,
|
||||||
|
thread_id=thread_root,
|
||||||
|
)
|
||||||
|
session.add(reply)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(reply)
|
||||||
|
return reply
|
||||||
|
|
||||||
|
return router
|
||||||
30
hub_core/routers/policy.py
Normal file
30
hub_core/routers/policy.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
from hub_core.schemas.policy import PolicyRead, PolicyUpdate
|
||||||
|
|
||||||
|
PolicyLoader = Callable[[str], PolicyRead | None]
|
||||||
|
PolicyUpdater = Callable[[str, str], PolicyRead]
|
||||||
|
|
||||||
|
|
||||||
|
def create_policy_router(
|
||||||
|
load_policy: PolicyLoader,
|
||||||
|
update_policy: PolicyUpdater | None = None,
|
||||||
|
) -> APIRouter:
|
||||||
|
router = APIRouter(prefix="/policy", tags=["policy"])
|
||||||
|
|
||||||
|
@router.get("/{name}", response_model=PolicyRead)
|
||||||
|
def get_policy(name: str) -> PolicyRead:
|
||||||
|
policy = load_policy(name)
|
||||||
|
if policy is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Policy '{name}' not found")
|
||||||
|
return policy
|
||||||
|
|
||||||
|
if update_policy is not None:
|
||||||
|
|
||||||
|
@router.put("/{name}", response_model=PolicyRead)
|
||||||
|
def put_policy(name: str, body: PolicyUpdate) -> PolicyRead:
|
||||||
|
return update_policy(name, body.content)
|
||||||
|
|
||||||
|
return router
|
||||||
130
hub_core/routers/progress.py
Normal file
130
hub_core/routers/progress.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import uuid
|
||||||
|
from collections.abc import Callable, Collection
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from hub_core.events import ALERT_EVENT_TYPES, RISK_EVENT_TYPES
|
||||||
|
from hub_core.models.progress_event import ProgressEvent
|
||||||
|
from hub_core.schemas.progress_event import ProgressEventCreate, ProgressEventRead
|
||||||
|
from hub_core.utils.pagination import PageParams, apply_pagination
|
||||||
|
|
||||||
|
|
||||||
|
def create_progress_router(
|
||||||
|
get_session: Callable[..., AsyncSession],
|
||||||
|
*,
|
||||||
|
progress_model: type[ProgressEvent] = ProgressEvent,
|
||||||
|
progress_create_schema: type[ProgressEventCreate] = ProgressEventCreate,
|
||||||
|
progress_read_schema: type[ProgressEventRead] = ProgressEventRead,
|
||||||
|
) -> APIRouter:
|
||||||
|
router = APIRouter(prefix="/progress", tags=["progress"])
|
||||||
|
list_response_model = list[progress_read_schema]
|
||||||
|
|
||||||
|
async def _list_events(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
topic_id: uuid.UUID | None = None,
|
||||||
|
workstream_id: uuid.UUID | None = None,
|
||||||
|
task_id: uuid.UUID | None = None,
|
||||||
|
decision_id: uuid.UUID | None = None,
|
||||||
|
event_type: str | None = None,
|
||||||
|
event_types: Collection[str] | None = None,
|
||||||
|
since: datetime | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[Any]:
|
||||||
|
q = select(progress_model)
|
||||||
|
for field, value in (
|
||||||
|
("topic_id", topic_id),
|
||||||
|
("workstream_id", workstream_id),
|
||||||
|
("task_id", task_id),
|
||||||
|
("decision_id", decision_id),
|
||||||
|
):
|
||||||
|
if value is not None:
|
||||||
|
column = getattr(progress_model, field, None)
|
||||||
|
if column is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Progress events do not support filtering by {field}",
|
||||||
|
)
|
||||||
|
q = q.where(column == value)
|
||||||
|
if event_type:
|
||||||
|
q = q.where(progress_model.event_type == event_type)
|
||||||
|
if event_types is not None:
|
||||||
|
q = q.where(progress_model.event_type.in_(sorted(event_types)))
|
||||||
|
if since:
|
||||||
|
q = q.where(progress_model.created_at >= since)
|
||||||
|
q = q.order_by(progress_model.created_at.desc())
|
||||||
|
q = apply_pagination(q, PageParams(limit=limit, offset=offset))
|
||||||
|
result = await session.execute(q)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@router.get("/", response_model=list_response_model)
|
||||||
|
async def list_progress(
|
||||||
|
topic_id: uuid.UUID | None = None,
|
||||||
|
workstream_id: uuid.UUID | None = None,
|
||||||
|
task_id: uuid.UUID | None = None,
|
||||||
|
decision_id: uuid.UUID | None = None,
|
||||||
|
event_type: str | None = None,
|
||||||
|
since: datetime | None = None,
|
||||||
|
limit: int = Query(100, le=1000),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[Any]:
|
||||||
|
return await _list_events(
|
||||||
|
session,
|
||||||
|
topic_id=topic_id,
|
||||||
|
workstream_id=workstream_id,
|
||||||
|
task_id=task_id,
|
||||||
|
decision_id=decision_id,
|
||||||
|
event_type=event_type,
|
||||||
|
since=since,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/risks", response_model=list_response_model)
|
||||||
|
async def get_risks(
|
||||||
|
since: datetime | None = None,
|
||||||
|
limit: int = Query(100, le=1000),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[Any]:
|
||||||
|
return await _list_events(
|
||||||
|
session,
|
||||||
|
event_types=RISK_EVENT_TYPES,
|
||||||
|
since=since,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/alerts", response_model=list_response_model)
|
||||||
|
async def get_alerts(
|
||||||
|
since: datetime | None = None,
|
||||||
|
limit: int = Query(100, le=1000),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[Any]:
|
||||||
|
return await _list_events(
|
||||||
|
session,
|
||||||
|
event_types=ALERT_EVENT_TYPES,
|
||||||
|
since=since,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/", response_model=progress_read_schema, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def append_progress(
|
||||||
|
body: progress_create_schema,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> Any:
|
||||||
|
event = progress_model(**body.model_dump())
|
||||||
|
session.add(event)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(event)
|
||||||
|
return event
|
||||||
|
|
||||||
|
return router
|
||||||
173
hub_core/routers/repos.py
Normal file
173
hub_core/routers/repos.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any, Awaitable
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import noload
|
||||||
|
|
||||||
|
from hub_core.models.domain import Domain
|
||||||
|
from hub_core.models.managed_repo import ManagedRepo
|
||||||
|
from hub_core.schemas.managed_repo import RepoCreate, RepoPathRegister, RepoRead, RepoUpdate
|
||||||
|
|
||||||
|
|
||||||
|
RepoRegisteredHook = Callable[[Any, Any, Any], Awaitable[None] | None]
|
||||||
|
|
||||||
|
|
||||||
|
def create_repos_router(
|
||||||
|
get_session: Callable[..., AsyncSession],
|
||||||
|
*,
|
||||||
|
prefix: str = "/repos",
|
||||||
|
domain_model: type[Domain] = Domain,
|
||||||
|
repo_model: type[ManagedRepo] = ManagedRepo,
|
||||||
|
repo_create_schema: type[RepoCreate] = RepoCreate,
|
||||||
|
repo_update_schema: type[RepoUpdate] = RepoUpdate,
|
||||||
|
repo_read_schema: type[RepoRead] = RepoRead,
|
||||||
|
repo_path_register_schema: type[RepoPathRegister] = RepoPathRegister,
|
||||||
|
list_noload_fields: tuple[str, ...] = ("domain",),
|
||||||
|
create_extension_fields: tuple[str, ...] = (),
|
||||||
|
after_register: RepoRegisteredHook | None = None,
|
||||||
|
include_collection_routes: bool = True,
|
||||||
|
include_lookup_routes: bool = True,
|
||||||
|
include_slug_routes: bool = True,
|
||||||
|
) -> APIRouter:
|
||||||
|
router = APIRouter(prefix=prefix, tags=["repos"])
|
||||||
|
list_response_model = list[repo_read_schema]
|
||||||
|
|
||||||
|
if include_collection_routes:
|
||||||
|
@router.get("/", response_model=list_response_model)
|
||||||
|
async def list_repos(
|
||||||
|
response: Response,
|
||||||
|
domain: str | None = None,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[Any]:
|
||||||
|
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
|
||||||
|
q = select(repo_model).options(
|
||||||
|
*[
|
||||||
|
noload(getattr(repo_model, field))
|
||||||
|
for field in list_noload_fields
|
||||||
|
if hasattr(repo_model, field)
|
||||||
|
]
|
||||||
|
).order_by(repo_model.name)
|
||||||
|
if domain:
|
||||||
|
domain_obj = await _get_domain_by_slug(domain, session, domain_model)
|
||||||
|
q = q.where(repo_model.domain_id == domain_obj.id)
|
||||||
|
result = await session.execute(q)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@router.post("/", response_model=repo_read_schema, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def register_repo(
|
||||||
|
body: repo_create_schema,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> Any:
|
||||||
|
domain_obj = await _get_domain_by_slug(body.domain_slug, session, domain_model)
|
||||||
|
existing = await session.execute(select(repo_model).where(repo_model.slug == body.slug))
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=409, detail=f"Repo slug '{body.slug}' already exists")
|
||||||
|
repo_attrs = {
|
||||||
|
"domain_id": domain_obj.id,
|
||||||
|
"slug": body.slug,
|
||||||
|
"name": body.name,
|
||||||
|
"local_path": body.local_path,
|
||||||
|
"host_paths": body.host_paths,
|
||||||
|
"remote_url": body.remote_url,
|
||||||
|
"git_fingerprint": body.git_fingerprint,
|
||||||
|
"description": body.description,
|
||||||
|
}
|
||||||
|
for field in create_extension_fields:
|
||||||
|
if hasattr(body, field) and hasattr(repo_model, field):
|
||||||
|
repo_attrs[field] = getattr(body, field)
|
||||||
|
repo = repo_model(**repo_attrs)
|
||||||
|
session.add(repo)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(repo)
|
||||||
|
if after_register is not None:
|
||||||
|
hook_result = after_register(repo, body, domain_obj)
|
||||||
|
if hook_result is not None:
|
||||||
|
await hook_result
|
||||||
|
return repo
|
||||||
|
|
||||||
|
if include_lookup_routes:
|
||||||
|
@router.get("/by-fingerprint", response_model=list_response_model)
|
||||||
|
async def get_repo_by_fingerprint(
|
||||||
|
hash: str,
|
||||||
|
remote_url: str | None = None,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[Any]:
|
||||||
|
q = select(repo_model).where(repo_model.git_fingerprint == hash)
|
||||||
|
if remote_url:
|
||||||
|
q = q.where(repo_model.remote_url == remote_url)
|
||||||
|
result = await session.execute(q)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@router.get("/by-remote", response_model=repo_read_schema)
|
||||||
|
async def get_repo_by_remote_url(
|
||||||
|
url: str,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> Any:
|
||||||
|
result = await session.execute(select(repo_model).where(repo_model.remote_url == url))
|
||||||
|
repo = result.scalar_one_or_none()
|
||||||
|
if repo is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"No repo with remote_url '{url}' found")
|
||||||
|
return repo
|
||||||
|
|
||||||
|
if include_slug_routes:
|
||||||
|
@router.get("/{slug}", response_model=repo_read_schema)
|
||||||
|
async def get_repo(
|
||||||
|
slug: str,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> Any:
|
||||||
|
return await _get_repo_by_slug(slug, session, repo_model)
|
||||||
|
|
||||||
|
@router.patch("/{slug}", response_model=repo_read_schema)
|
||||||
|
async def update_repo(
|
||||||
|
slug: str,
|
||||||
|
body: repo_update_schema,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> Any:
|
||||||
|
repo = await _get_repo_by_slug(slug, session, repo_model)
|
||||||
|
for field, value in body.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(repo, field, value)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(repo)
|
||||||
|
return repo
|
||||||
|
|
||||||
|
@router.post("/{slug}/paths", response_model=repo_read_schema)
|
||||||
|
async def register_repo_path(
|
||||||
|
slug: str,
|
||||||
|
body: repo_path_register_schema,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> Any:
|
||||||
|
repo = await _get_repo_by_slug(slug, session, repo_model)
|
||||||
|
host_paths = dict(repo.host_paths or {})
|
||||||
|
host_paths[body.host] = body.path
|
||||||
|
repo.host_paths = host_paths
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(repo)
|
||||||
|
return repo
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_domain_by_slug(
|
||||||
|
slug: str,
|
||||||
|
session: AsyncSession,
|
||||||
|
domain_model: type[Domain],
|
||||||
|
) -> Any:
|
||||||
|
result = await session.execute(select(domain_model).where(domain_model.slug == slug))
|
||||||
|
domain = result.scalar_one_or_none()
|
||||||
|
if domain is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Domain '{slug}' not found")
|
||||||
|
return domain
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_repo_by_slug(
|
||||||
|
slug: str,
|
||||||
|
session: AsyncSession,
|
||||||
|
repo_model: type[ManagedRepo],
|
||||||
|
) -> Any:
|
||||||
|
result = await session.execute(select(repo_model).where(repo_model.slug == slug))
|
||||||
|
repo = result.scalar_one_or_none()
|
||||||
|
if repo is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
|
||||||
|
return repo
|
||||||
240
hub_core/routers/tpsc.py
Normal file
240
hub_core/routers/tpsc.py
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from hub_core.models.managed_repo import ManagedRepo
|
||||||
|
from hub_core.models.tpsc import TPSCCatalog, TPSCEntry, TPSCSnapshot
|
||||||
|
from hub_core.schemas.tpsc import (
|
||||||
|
GDPR_WARNING_LEVELS,
|
||||||
|
TPSCCatalogCreate,
|
||||||
|
TPSCCatalogRead,
|
||||||
|
TPSCEntryRead,
|
||||||
|
TPSCGDPRReport,
|
||||||
|
TPSCGDPRWarning,
|
||||||
|
TPSCIngestRequest,
|
||||||
|
TPSCSnapshotRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_tpsc_router(
|
||||||
|
get_session: Callable[..., AsyncSession],
|
||||||
|
*,
|
||||||
|
repo_model: type[ManagedRepo] = ManagedRepo,
|
||||||
|
catalog_model: type[TPSCCatalog] = TPSCCatalog,
|
||||||
|
snapshot_model: type[TPSCSnapshot] = TPSCSnapshot,
|
||||||
|
entry_model: type[TPSCEntry] = TPSCEntry,
|
||||||
|
) -> APIRouter:
|
||||||
|
router = APIRouter(prefix="/tpsc", tags=["tpsc"])
|
||||||
|
|
||||||
|
@router.get("/catalog/", response_model=list[TPSCCatalogRead])
|
||||||
|
async def list_catalog(
|
||||||
|
gdpr_maturity: str | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
pricing_model: str | None = None,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[Any]:
|
||||||
|
q = select(catalog_model).where(catalog_model.status != "deprecated")
|
||||||
|
if gdpr_maturity:
|
||||||
|
q = q.where(catalog_model.gdpr_maturity == gdpr_maturity)
|
||||||
|
if category:
|
||||||
|
q = q.where(catalog_model.category == category)
|
||||||
|
if pricing_model:
|
||||||
|
q = q.where(catalog_model.pricing_model == pricing_model)
|
||||||
|
q = q.order_by(catalog_model.name)
|
||||||
|
result = await session.execute(q)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@router.get("/catalog/{slug}", response_model=TPSCCatalogRead)
|
||||||
|
async def get_catalog_entry(
|
||||||
|
slug: str,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> Any:
|
||||||
|
row = (
|
||||||
|
await session.execute(select(catalog_model).where(catalog_model.slug == slug))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Service '{slug}' not found in catalog")
|
||||||
|
return row
|
||||||
|
|
||||||
|
@router.post("/catalog/", response_model=TPSCCatalogRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def register_service(
|
||||||
|
body: TPSCCatalogCreate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> Any:
|
||||||
|
existing = (
|
||||||
|
await session.execute(select(catalog_model).where(catalog_model.slug == body.slug))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing:
|
||||||
|
for field, value in body.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(existing, field, value)
|
||||||
|
existing.updated_at = datetime.now(tz=timezone.utc)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(existing)
|
||||||
|
return existing
|
||||||
|
entry = catalog_model(**body.model_dump())
|
||||||
|
session.add(entry)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(entry)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
@router.post("/ingest/", response_model=TPSCSnapshotRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def ingest_tpsc(
|
||||||
|
body: TPSCIngestRequest,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> TPSCSnapshotRead:
|
||||||
|
repo = (
|
||||||
|
await session.execute(select(repo_model).where(repo_model.slug == body.repo_slug))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
repo_id = repo.id if repo else None
|
||||||
|
slugs = {entry.service_slug for entry in body.entries}
|
||||||
|
catalog_rows = []
|
||||||
|
if slugs:
|
||||||
|
catalog_rows = (
|
||||||
|
await session.execute(select(catalog_model).where(catalog_model.slug.in_(slugs)))
|
||||||
|
).scalars().all()
|
||||||
|
catalog_map = {row.slug: row for row in catalog_rows}
|
||||||
|
|
||||||
|
snapshot = snapshot_model(
|
||||||
|
repo_id=repo_id,
|
||||||
|
source_file=body.source_file,
|
||||||
|
entry_count=len(body.entries),
|
||||||
|
)
|
||||||
|
session.add(snapshot)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
entries_with_catalogs = []
|
||||||
|
for body_entry in body.entries:
|
||||||
|
catalog_entry = catalog_map.get(body_entry.service_slug)
|
||||||
|
entry = entry_model(
|
||||||
|
snapshot_id=snapshot.id,
|
||||||
|
catalog_id=catalog_entry.id if catalog_entry else None,
|
||||||
|
**body_entry.model_dump(),
|
||||||
|
)
|
||||||
|
session.add(entry)
|
||||||
|
entries_with_catalogs.append((entry, catalog_entry))
|
||||||
|
|
||||||
|
await session.flush()
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(snapshot)
|
||||||
|
|
||||||
|
return TPSCSnapshotRead(
|
||||||
|
id=snapshot.id,
|
||||||
|
repo_id=snapshot.repo_id,
|
||||||
|
snapshot_at=snapshot.snapshot_at,
|
||||||
|
source_file=snapshot.source_file,
|
||||||
|
entry_count=snapshot.entry_count,
|
||||||
|
entries=[
|
||||||
|
_entry_read(entry, catalog_entry)
|
||||||
|
for entry, catalog_entry in entries_with_catalogs
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/snapshots/", response_model=list[TPSCSnapshotRead])
|
||||||
|
async def list_snapshots(
|
||||||
|
repo_slug: str | None = None,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[TPSCSnapshotRead]:
|
||||||
|
q = select(snapshot_model).options(
|
||||||
|
selectinload(snapshot_model.entries).selectinload(entry_model.catalog_entry)
|
||||||
|
)
|
||||||
|
if repo_slug:
|
||||||
|
repo = (
|
||||||
|
await session.execute(select(repo_model).where(repo_model.slug == repo_slug))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if repo is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Repo '{repo_slug}' not found")
|
||||||
|
q = q.where(snapshot_model.repo_id == repo.id)
|
||||||
|
q = q.order_by(snapshot_model.snapshot_at.desc())
|
||||||
|
rows = (await session.execute(q)).scalars().all()
|
||||||
|
return [_snapshot_read(row) for row in rows]
|
||||||
|
|
||||||
|
@router.get("/report/gdpr", response_model=TPSCGDPRReport)
|
||||||
|
async def gdpr_report(
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> TPSCGDPRReport:
|
||||||
|
latest_sub = (
|
||||||
|
select(snapshot_model.repo_id, func.max(snapshot_model.snapshot_at).label("max_at"))
|
||||||
|
.group_by(snapshot_model.repo_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
latest_snaps = (
|
||||||
|
await session.execute(
|
||||||
|
select(snapshot_model)
|
||||||
|
.join(
|
||||||
|
latest_sub,
|
||||||
|
(snapshot_model.repo_id == latest_sub.c.repo_id)
|
||||||
|
& (snapshot_model.snapshot_at == latest_sub.c.max_at),
|
||||||
|
)
|
||||||
|
.options(selectinload(snapshot_model.entries).selectinload(entry_model.catalog_entry))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
all_repos = (await session.execute(select(repo_model))).scalars().all()
|
||||||
|
repo_map = {repo.id: repo.slug for repo in all_repos}
|
||||||
|
all_services = (await session.execute(select(catalog_model))).scalars().all()
|
||||||
|
by_maturity: dict[str, int] = {}
|
||||||
|
for service in all_services:
|
||||||
|
by_maturity[service.gdpr_maturity] = by_maturity.get(service.gdpr_maturity, 0) + 1
|
||||||
|
|
||||||
|
warnings = []
|
||||||
|
seen = set()
|
||||||
|
for snap in latest_snaps:
|
||||||
|
repo_slug = repo_map.get(snap.repo_id) if snap.repo_id else None
|
||||||
|
for entry in snap.entries:
|
||||||
|
catalog_entry = entry.catalog_entry
|
||||||
|
maturity = catalog_entry.gdpr_maturity if catalog_entry else "unknown"
|
||||||
|
if maturity not in GDPR_WARNING_LEVELS:
|
||||||
|
continue
|
||||||
|
key = (repo_slug, entry.service_slug)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
warnings.append(
|
||||||
|
TPSCGDPRWarning(
|
||||||
|
repo_slug=repo_slug,
|
||||||
|
service_slug=entry.service_slug,
|
||||||
|
gdpr_maturity=maturity,
|
||||||
|
purpose=entry.purpose,
|
||||||
|
pricing_model=catalog_entry.pricing_model if catalog_entry else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return TPSCGDPRReport(
|
||||||
|
generated_at=datetime.now(tz=timezone.utc),
|
||||||
|
total_services=len(all_services),
|
||||||
|
warning_count=len(warnings),
|
||||||
|
warnings=warnings,
|
||||||
|
by_maturity=by_maturity,
|
||||||
|
)
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_read(entry: TPSCEntry, catalog_entry: TPSCCatalog | None) -> TPSCEntryRead:
|
||||||
|
return TPSCEntryRead(
|
||||||
|
id=entry.id,
|
||||||
|
snapshot_id=entry.snapshot_id,
|
||||||
|
catalog_id=entry.catalog_id,
|
||||||
|
service_slug=entry.service_slug,
|
||||||
|
purpose=entry.purpose,
|
||||||
|
auth_type=entry.auth_type,
|
||||||
|
endpoint_override=entry.endpoint_override,
|
||||||
|
notes=entry.notes,
|
||||||
|
gdpr_maturity=catalog_entry.gdpr_maturity if catalog_entry else None,
|
||||||
|
gdpr_warning=(catalog_entry.gdpr_maturity in GDPR_WARNING_LEVELS) if catalog_entry else True,
|
||||||
|
pricing_model=catalog_entry.pricing_model if catalog_entry else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_read(snapshot: TPSCSnapshot) -> TPSCSnapshotRead:
|
||||||
|
return TPSCSnapshotRead(
|
||||||
|
id=snapshot.id,
|
||||||
|
repo_id=snapshot.repo_id,
|
||||||
|
snapshot_at=snapshot.snapshot_at,
|
||||||
|
source_file=snapshot.source_file,
|
||||||
|
entry_count=snapshot.entry_count,
|
||||||
|
entries=[_entry_read(entry, entry.catalog_entry) for entry in snapshot.entries],
|
||||||
|
)
|
||||||
74
hub_core/schemas/__init__.py
Normal file
74
hub_core/schemas/__init__.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
from hub_core.schemas.agent_message import MessageCreate, MessageRead, MessageReply
|
||||||
|
from hub_core.schemas.capability import (
|
||||||
|
CapabilityRequestAccept,
|
||||||
|
CapabilityRequestCreate,
|
||||||
|
CapabilityRequestDispute,
|
||||||
|
CapabilityRequestPatch,
|
||||||
|
CapabilityRequestRead,
|
||||||
|
CapabilityRequestStatusPatch,
|
||||||
|
CatalogCreate,
|
||||||
|
CatalogPatch,
|
||||||
|
CatalogRead,
|
||||||
|
)
|
||||||
|
from hub_core.schemas.doi import DoICriterion, DoIReport, DoISummaryEntry
|
||||||
|
from hub_core.schemas.domain import DomainCreate, DomainDetail, DomainRead, DomainRename, DomainUpdate
|
||||||
|
from hub_core.schemas.managed_repo import RepoCreate, RepoPathRegister, RepoRead, RepoUpdate
|
||||||
|
from hub_core.schemas.policy import PolicyRead, PolicyUpdate
|
||||||
|
from hub_core.schemas.progress_event import ProgressEventCreate, ProgressEventRead
|
||||||
|
from hub_core.schemas.tpsc import (
|
||||||
|
AuthType,
|
||||||
|
GDPRMaturity,
|
||||||
|
GDPR_WARNING_LEVELS,
|
||||||
|
PricingModel,
|
||||||
|
TPSCCatalogCreate,
|
||||||
|
TPSCCatalogRead,
|
||||||
|
TPSCEntryCreate,
|
||||||
|
TPSCEntryRead,
|
||||||
|
TPSCGDPRReport,
|
||||||
|
TPSCGDPRWarning,
|
||||||
|
TPSCIngestRequest,
|
||||||
|
TPSCSnapshotRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CapabilityRequestAccept",
|
||||||
|
"CapabilityRequestCreate",
|
||||||
|
"CapabilityRequestDispute",
|
||||||
|
"CapabilityRequestPatch",
|
||||||
|
"CapabilityRequestRead",
|
||||||
|
"CapabilityRequestStatusPatch",
|
||||||
|
"CatalogCreate",
|
||||||
|
"CatalogPatch",
|
||||||
|
"CatalogRead",
|
||||||
|
"DoICriterion",
|
||||||
|
"DoIReport",
|
||||||
|
"DoISummaryEntry",
|
||||||
|
"DomainCreate",
|
||||||
|
"DomainDetail",
|
||||||
|
"DomainRead",
|
||||||
|
"DomainRename",
|
||||||
|
"DomainUpdate",
|
||||||
|
"AuthType",
|
||||||
|
"GDPRMaturity",
|
||||||
|
"GDPR_WARNING_LEVELS",
|
||||||
|
"MessageCreate",
|
||||||
|
"MessageRead",
|
||||||
|
"MessageReply",
|
||||||
|
"PolicyRead",
|
||||||
|
"PolicyUpdate",
|
||||||
|
"PricingModel",
|
||||||
|
"ProgressEventCreate",
|
||||||
|
"ProgressEventRead",
|
||||||
|
"RepoCreate",
|
||||||
|
"RepoPathRegister",
|
||||||
|
"RepoRead",
|
||||||
|
"RepoUpdate",
|
||||||
|
"TPSCCatalogCreate",
|
||||||
|
"TPSCCatalogRead",
|
||||||
|
"TPSCEntryCreate",
|
||||||
|
"TPSCEntryRead",
|
||||||
|
"TPSCGDPRReport",
|
||||||
|
"TPSCGDPRWarning",
|
||||||
|
"TPSCIngestRequest",
|
||||||
|
"TPSCSnapshotRead",
|
||||||
|
]
|
||||||
31
hub_core/schemas/agent_message.py
Normal file
31
hub_core/schemas/agent_message.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class MessageCreate(BaseModel):
|
||||||
|
from_agent: str
|
||||||
|
to_agent: str
|
||||||
|
subject: str
|
||||||
|
body: str
|
||||||
|
thread_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MessageReply(BaseModel):
|
||||||
|
from_agent: str
|
||||||
|
body: str
|
||||||
|
|
||||||
|
|
||||||
|
class MessageRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
from_agent: str
|
||||||
|
to_agent: str
|
||||||
|
subject: str
|
||||||
|
body: str
|
||||||
|
thread_id: uuid.UUID | None = None
|
||||||
|
read_at: datetime | None = None
|
||||||
|
archived_at: datetime | None = None
|
||||||
|
created_at: datetime
|
||||||
99
hub_core/schemas/capability.py
Normal file
99
hub_core/schemas/capability.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogCreate(BaseModel):
|
||||||
|
domain: str
|
||||||
|
capability_type: str
|
||||||
|
title: str
|
||||||
|
description: str | None = None
|
||||||
|
keywords: list[str] = Field(default_factory=list)
|
||||||
|
repo_slug: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogPatch(BaseModel):
|
||||||
|
repo_slug: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
keywords: list[str] | None = None
|
||||||
|
status: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
domain_slug: str
|
||||||
|
repo_id: uuid.UUID | None = None
|
||||||
|
repo_slug: str | None = None
|
||||||
|
capability_type: str
|
||||||
|
title: str
|
||||||
|
description: str | None = None
|
||||||
|
keywords: list[str] = Field(default_factory=list)
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityRequestCreate(BaseModel):
|
||||||
|
title: str
|
||||||
|
description: str | None = None
|
||||||
|
capability_type: str
|
||||||
|
priority: str = "medium"
|
||||||
|
requesting_domain: str
|
||||||
|
requesting_agent: str
|
||||||
|
request_context: dict[str, Any] | None = None
|
||||||
|
catalog_entry_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityRequestAccept(BaseModel):
|
||||||
|
fulfilling_agent: str
|
||||||
|
fulfillment_context: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityRequestStatusPatch(BaseModel):
|
||||||
|
status: str
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityRequestPatch(BaseModel):
|
||||||
|
catalog_entry_id: uuid.UUID | None = None
|
||||||
|
priority: str | None = None
|
||||||
|
request_context: dict[str, Any] | None = None
|
||||||
|
fulfillment_context: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityRequestDispute(BaseModel):
|
||||||
|
reason: str
|
||||||
|
disputed_by: str
|
||||||
|
suggested_domain: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityRequestRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
title: str
|
||||||
|
description: str | None = None
|
||||||
|
capability_type: str
|
||||||
|
priority: str
|
||||||
|
status: str
|
||||||
|
requesting_domain_slug: str
|
||||||
|
requesting_agent: str
|
||||||
|
request_context: dict[str, Any] | None = None
|
||||||
|
fulfilling_domain_slug: str | None = None
|
||||||
|
fulfilling_agent: str | None = None
|
||||||
|
fulfillment_context: dict[str, Any] | None = None
|
||||||
|
catalog_entry_id: uuid.UUID | None = None
|
||||||
|
resolution_note: str | None = None
|
||||||
|
routing_note: str | None = None
|
||||||
|
dispute_reason: str | None = None
|
||||||
|
disputed_by: str | None = None
|
||||||
|
dispute_suggested_domain: str | None = None
|
||||||
|
disputed_at: datetime | None = None
|
||||||
|
accepted_at: datetime | None = None
|
||||||
|
completed_at: datetime | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
29
hub_core/schemas/doi.py
Normal file
29
hub_core/schemas/doi.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class DoICriterion(BaseModel):
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
tier: str
|
||||||
|
status: str
|
||||||
|
detail: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class DoIReport(BaseModel):
|
||||||
|
repo_slug: str
|
||||||
|
tier: str
|
||||||
|
core_pass: bool
|
||||||
|
standard_pass: bool
|
||||||
|
full_pass: bool
|
||||||
|
criteria: list[DoICriterion] = []
|
||||||
|
checked_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class DoISummaryEntry(BaseModel):
|
||||||
|
repo_slug: str
|
||||||
|
domain_slug: str | None
|
||||||
|
tier: str
|
||||||
|
core_pass: bool
|
||||||
|
standard_pass: bool
|
||||||
|
full_pass: bool
|
||||||
|
checked_at: str
|
||||||
49
hub_core/schemas/domain.py
Normal file
49
hub_core/schemas/domain.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class DomainCreate(BaseModel):
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DomainUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
status: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DomainRename(BaseModel):
|
||||||
|
new_slug: str
|
||||||
|
new_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class RepoStub(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
local_path: str | None = None
|
||||||
|
remote_url: str | None = None
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class DomainRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class DomainDetail(DomainRead):
|
||||||
|
repos: list[RepoStub] = Field(default_factory=list)
|
||||||
|
extension_counts: dict[str, int] = Field(default_factory=dict)
|
||||||
48
hub_core/schemas/managed_repo.py
Normal file
48
hub_core/schemas/managed_repo.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class RepoCreate(BaseModel):
|
||||||
|
domain_slug: str
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
local_path: str | None = None
|
||||||
|
host_paths: dict = Field(default_factory=dict)
|
||||||
|
remote_url: str | None = None
|
||||||
|
git_fingerprint: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RepoUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
local_path: str | None = None
|
||||||
|
host_paths: dict | None = None
|
||||||
|
remote_url: str | None = None
|
||||||
|
git_fingerprint: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
status: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RepoPathRegister(BaseModel):
|
||||||
|
host: str
|
||||||
|
path: str
|
||||||
|
|
||||||
|
|
||||||
|
class RepoRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
domain_id: uuid.UUID
|
||||||
|
domain_slug: str
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
local_path: str | None = None
|
||||||
|
host_paths: dict = Field(default_factory=dict)
|
||||||
|
remote_url: str | None = None
|
||||||
|
git_fingerprint: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
10
hub_core/schemas/policy.py
Normal file
10
hub_core/schemas/policy.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class PolicyRead(BaseModel):
|
||||||
|
name: str
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class PolicyUpdate(BaseModel):
|
||||||
|
content: str
|
||||||
27
hub_core/schemas/progress_event.py
Normal file
27
hub_core/schemas/progress_event.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressEventCreate(BaseModel):
|
||||||
|
event_type: str
|
||||||
|
summary: str
|
||||||
|
detail: dict[str, Any] | None = None
|
||||||
|
subject_refs: dict[str, Any] | None = None
|
||||||
|
author: str | None = None
|
||||||
|
session_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressEventRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
event_type: str
|
||||||
|
summary: str
|
||||||
|
detail: dict[str, Any] | None = None
|
||||||
|
subject_refs: dict[str, Any] | None = None
|
||||||
|
author: str | None = None
|
||||||
|
session_id: str | None = None
|
||||||
|
created_at: datetime
|
||||||
116
hub_core/schemas/tpsc.py
Normal file
116
hub_core/schemas/tpsc.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
||||||
|
|
||||||
|
|
||||||
|
GDPRMaturity = Literal[
|
||||||
|
"unknown",
|
||||||
|
"non_compliant",
|
||||||
|
"initial",
|
||||||
|
"developing",
|
||||||
|
"defined",
|
||||||
|
"managed",
|
||||||
|
"certified",
|
||||||
|
]
|
||||||
|
GDPR_WARNING_LEVELS = {"unknown", "non_compliant", "initial"}
|
||||||
|
|
||||||
|
PricingModel = Literal["free", "paid", "freemium", "usage_based", "unknown"]
|
||||||
|
AuthType = Literal["api_key", "oauth", "cli", "none", "unknown"]
|
||||||
|
|
||||||
|
|
||||||
|
class TPSCCatalogCreate(BaseModel):
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
provider: str | None = None
|
||||||
|
category: str | None = None
|
||||||
|
website_url: str | None = None
|
||||||
|
pricing_model: PricingModel = "unknown"
|
||||||
|
gdpr_maturity: GDPRMaturity = "unknown"
|
||||||
|
gdpr_notes: str | None = None
|
||||||
|
dpa_available: bool = False
|
||||||
|
tos_url: str | None = None
|
||||||
|
privacy_policy_url: str | None = None
|
||||||
|
data_processing_regions: list[str] | None = None
|
||||||
|
data_retention_notes: str | None = None
|
||||||
|
status: str = "active"
|
||||||
|
|
||||||
|
|
||||||
|
class TPSCCatalogRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
provider: str | None
|
||||||
|
category: str | None
|
||||||
|
website_url: str | None
|
||||||
|
pricing_model: str
|
||||||
|
gdpr_maturity: str
|
||||||
|
gdpr_notes: str | None
|
||||||
|
dpa_available: bool
|
||||||
|
tos_url: str | None
|
||||||
|
privacy_policy_url: str | None
|
||||||
|
data_processing_regions: list[str] | None
|
||||||
|
data_retention_notes: str | None
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def gdpr_warning(self) -> bool:
|
||||||
|
return self.gdpr_maturity in GDPR_WARNING_LEVELS
|
||||||
|
|
||||||
|
|
||||||
|
class TPSCEntryCreate(BaseModel):
|
||||||
|
service_slug: str
|
||||||
|
purpose: str | None = None
|
||||||
|
auth_type: str | None = None
|
||||||
|
endpoint_override: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TPSCEntryRead(TPSCEntryCreate):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
snapshot_id: uuid.UUID
|
||||||
|
catalog_id: uuid.UUID | None = None
|
||||||
|
gdpr_maturity: str | None = None
|
||||||
|
gdpr_warning: bool = False
|
||||||
|
pricing_model: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TPSCIngestRequest(BaseModel):
|
||||||
|
repo_slug: str
|
||||||
|
source_file: str = "tpsc.yaml"
|
||||||
|
entries: list[TPSCEntryCreate]
|
||||||
|
|
||||||
|
|
||||||
|
class TPSCSnapshotRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
repo_id: uuid.UUID | None = None
|
||||||
|
snapshot_at: datetime
|
||||||
|
source_file: str | None = None
|
||||||
|
entry_count: int
|
||||||
|
entries: list[TPSCEntryRead] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class TPSCGDPRWarning(BaseModel):
|
||||||
|
repo_slug: str | None
|
||||||
|
service_slug: str
|
||||||
|
gdpr_maturity: str
|
||||||
|
purpose: str | None
|
||||||
|
pricing_model: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class TPSCGDPRReport(BaseModel):
|
||||||
|
generated_at: datetime
|
||||||
|
total_services: int
|
||||||
|
warning_count: int
|
||||||
|
warnings: list[TPSCGDPRWarning]
|
||||||
|
by_maturity: dict[str, int]
|
||||||
12
hub_core/utils/__init__.py
Normal file
12
hub_core/utils/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from hub_core.utils.pagination import PageParams, apply_pagination
|
||||||
|
from hub_core.utils.paths import resolve_repo_path
|
||||||
|
from hub_core.utils.routing import normalize_trailing_slash
|
||||||
|
from hub_core.utils.slugs import slugify
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PageParams",
|
||||||
|
"apply_pagination",
|
||||||
|
"normalize_trailing_slash",
|
||||||
|
"resolve_repo_path",
|
||||||
|
"slugify",
|
||||||
|
]
|
||||||
22
hub_core/utils/pagination.py
Normal file
22
hub_core/utils/pagination.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
from sqlalchemy.sql import Select
|
||||||
|
|
||||||
|
SelectT = TypeVar("SelectT", bound=Select)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PageParams:
|
||||||
|
limit: int = 100
|
||||||
|
offset: int = 0
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.limit < 1 or self.limit > 1000:
|
||||||
|
raise ValueError("limit must be between 1 and 1000")
|
||||||
|
if self.offset < 0:
|
||||||
|
raise ValueError("offset must be >= 0")
|
||||||
|
|
||||||
|
|
||||||
|
def apply_pagination(query: SelectT, page: PageParams) -> SelectT:
|
||||||
|
return query.offset(page.offset).limit(page.limit)
|
||||||
13
hub_core/utils/paths.py
Normal file
13
hub_core/utils/paths.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import socket
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class RepoPathLike(Protocol):
|
||||||
|
local_path: str | None
|
||||||
|
host_paths: dict
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_repo_path(repo: RepoPathLike, host: str | None = None) -> str | None:
|
||||||
|
selected_host = host or socket.gethostname()
|
||||||
|
host_paths = repo.host_paths or {}
|
||||||
|
return host_paths.get(selected_host) or repo.local_path
|
||||||
27
hub_core/utils/routing.py
Normal file
27
hub_core/utils/routing.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from urllib.parse import SplitResult, urlsplit, urlunsplit
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_trailing_slash(path_or_url: str, *, trailing: bool = True) -> str:
|
||||||
|
"""Normalize the path component while preserving query and fragment."""
|
||||||
|
if not path_or_url:
|
||||||
|
return "/" if trailing else ""
|
||||||
|
|
||||||
|
parts = urlsplit(path_or_url)
|
||||||
|
path = parts.path or "/"
|
||||||
|
if trailing:
|
||||||
|
normalized_path = path if path.endswith("/") else f"{path}/"
|
||||||
|
elif path == "/":
|
||||||
|
normalized_path = "/"
|
||||||
|
else:
|
||||||
|
normalized_path = path.rstrip("/")
|
||||||
|
|
||||||
|
if parts.scheme or parts.netloc:
|
||||||
|
return urlunsplit(
|
||||||
|
SplitResult(parts.scheme, parts.netloc, normalized_path, parts.query, parts.fragment)
|
||||||
|
)
|
||||||
|
suffix = ""
|
||||||
|
if parts.query:
|
||||||
|
suffix += f"?{parts.query}"
|
||||||
|
if parts.fragment:
|
||||||
|
suffix += f"#{parts.fragment}"
|
||||||
|
return f"{normalized_path}{suffix}"
|
||||||
14
hub_core/utils/slugs.py
Normal file
14
hub_core/utils/slugs.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
_NON_SLUG = re.compile(r"[^a-z0-9]+")
|
||||||
|
_DASHES = re.compile(r"-+")
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(value: str, *, max_length: int = 100) -> str:
|
||||||
|
slug = _NON_SLUG.sub("-", value.strip().lower())
|
||||||
|
slug = _DASHES.sub("-", slug).strip("-")
|
||||||
|
if not slug:
|
||||||
|
raise ValueError("slug cannot be empty")
|
||||||
|
if max_length < 1:
|
||||||
|
raise ValueError("max_length must be >= 1")
|
||||||
|
return slug[:max_length].strip("-")
|
||||||
23
pyproject.toml
Normal file
23
pyproject.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[project]
|
||||||
|
name = "hub-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Reusable core primitives for FOS hubs"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115.0",
|
||||||
|
"fastmcp>=2.0.0",
|
||||||
|
"httpx>=0.28.0",
|
||||||
|
"sqlalchemy[asyncio]>=2.0.0",
|
||||||
|
"pydantic>=2.10.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["hub_core"]
|
||||||
|
artifacts = ["hub_core/migrations/script.py.mako"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
version: 1
|
version: 1
|
||||||
updated: '2026-06-16'
|
updated: '2026-06-16'
|
||||||
domain: helix_forge
|
domain: inter_hub
|
||||||
capabilities: []
|
capabilities: []
|
||||||
|
|||||||
391
tests/test_imports.py
Normal file
391
tests/test_imports.py
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from hub_core.events import ALERT_EVENT_TYPES, FOS10_EVENT_TYPES, RISK_EVENT_TYPES
|
||||||
|
from hub_core.models import Base
|
||||||
|
from hub_core.models.agent_message import AgentMessage
|
||||||
|
from hub_core.models.capability_catalog import CapabilityCatalog
|
||||||
|
from hub_core.models.capability_request import CapabilityRequest
|
||||||
|
from hub_core.models.domain import Domain
|
||||||
|
from hub_core.models.managed_repo import ManagedRepo
|
||||||
|
from hub_core.models.progress_event import ProgressEvent
|
||||||
|
from hub_core.models.tpsc import TPSCCatalog, TPSCEntry, TPSCSnapshot
|
||||||
|
from hub_core.routers import (
|
||||||
|
create_capabilities_router,
|
||||||
|
create_capability_catalog_router,
|
||||||
|
create_capability_request_read_router,
|
||||||
|
create_domains_router,
|
||||||
|
create_messages_router,
|
||||||
|
create_policy_router,
|
||||||
|
create_progress_router,
|
||||||
|
create_repos_router,
|
||||||
|
create_tpsc_router,
|
||||||
|
)
|
||||||
|
from hub_core.schemas.capability import (
|
||||||
|
CapabilityRequestRead,
|
||||||
|
CatalogCreate,
|
||||||
|
CatalogPatch,
|
||||||
|
CatalogRead,
|
||||||
|
)
|
||||||
|
from hub_core.schemas.domain import (
|
||||||
|
DomainCreate,
|
||||||
|
DomainDetail,
|
||||||
|
DomainRead,
|
||||||
|
DomainRename,
|
||||||
|
DomainUpdate,
|
||||||
|
RepoStub,
|
||||||
|
)
|
||||||
|
from hub_core.schemas.doi import DoICriterion, DoIReport, DoISummaryEntry
|
||||||
|
from hub_core.schemas.managed_repo import (
|
||||||
|
RepoCreate,
|
||||||
|
RepoPathRegister,
|
||||||
|
RepoRead,
|
||||||
|
RepoUpdate,
|
||||||
|
)
|
||||||
|
from hub_core.schemas.policy import PolicyRead
|
||||||
|
from hub_core.schemas.progress_event import ProgressEventCreate, ProgressEventRead
|
||||||
|
from hub_core.schemas.tpsc import TPSCCatalogRead, TPSCGDPRReport, TPSCGDPRWarning
|
||||||
|
|
||||||
|
|
||||||
|
def test_core_tables_are_registered() -> None:
|
||||||
|
assert set(Base.metadata.tables) == {
|
||||||
|
"agent_messages",
|
||||||
|
"capability_catalog",
|
||||||
|
"capability_requests",
|
||||||
|
"domains",
|
||||||
|
"managed_repos",
|
||||||
|
"progress_events",
|
||||||
|
"tpsc_catalog",
|
||||||
|
"tpsc_entries",
|
||||||
|
"tpsc_snapshots",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_table_names() -> None:
|
||||||
|
assert AgentMessage.__tablename__ == "agent_messages"
|
||||||
|
assert CapabilityCatalog.__tablename__ == "capability_catalog"
|
||||||
|
assert CapabilityRequest.__tablename__ == "capability_requests"
|
||||||
|
assert Domain.__tablename__ == "domains"
|
||||||
|
assert ManagedRepo.__tablename__ == "managed_repos"
|
||||||
|
assert ProgressEvent.__tablename__ == "progress_events"
|
||||||
|
assert TPSCCatalog.__tablename__ == "tpsc_catalog"
|
||||||
|
assert TPSCEntry.__tablename__ == "tpsc_entries"
|
||||||
|
assert TPSCSnapshot.__tablename__ == "tpsc_snapshots"
|
||||||
|
|
||||||
|
|
||||||
|
def test_doi_schemas_are_available() -> None:
|
||||||
|
criterion = DoICriterion(id="C1", label="Canonical files", tier="core", status="pass")
|
||||||
|
report = DoIReport(
|
||||||
|
repo_slug="example",
|
||||||
|
tier="core",
|
||||||
|
core_pass=True,
|
||||||
|
standard_pass=False,
|
||||||
|
full_pass=False,
|
||||||
|
criteria=[criterion],
|
||||||
|
checked_at="2026-06-07T00:00:00+00:00",
|
||||||
|
)
|
||||||
|
summary = DoISummaryEntry(
|
||||||
|
repo_slug="example",
|
||||||
|
domain_slug="custodian",
|
||||||
|
tier="core",
|
||||||
|
core_pass=True,
|
||||||
|
standard_pass=False,
|
||||||
|
full_pass=False,
|
||||||
|
checked_at=report.checked_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert report.criteria[0].id == "C1"
|
||||||
|
assert summary.domain_slug == "custodian"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tpsc_schemas_match_state_hub_contract() -> None:
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
catalog_entry = TPSCCatalogRead(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
slug="example-service",
|
||||||
|
name="Example Service",
|
||||||
|
provider="Example",
|
||||||
|
category="ops",
|
||||||
|
website_url=None,
|
||||||
|
pricing_model="paid",
|
||||||
|
gdpr_maturity="unknown",
|
||||||
|
gdpr_notes=None,
|
||||||
|
dpa_available=False,
|
||||||
|
tos_url=None,
|
||||||
|
privacy_policy_url=None,
|
||||||
|
data_processing_regions=None,
|
||||||
|
data_retention_notes=None,
|
||||||
|
status="active",
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
warning = TPSCGDPRWarning(
|
||||||
|
repo_slug="state-hub",
|
||||||
|
service_slug="example-service",
|
||||||
|
gdpr_maturity="unknown",
|
||||||
|
purpose="testing",
|
||||||
|
pricing_model="paid",
|
||||||
|
)
|
||||||
|
report = TPSCGDPRReport(
|
||||||
|
generated_at=now,
|
||||||
|
total_services=1,
|
||||||
|
warning_count=1,
|
||||||
|
warnings=[warning],
|
||||||
|
by_maturity={"unknown": 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert catalog_entry.gdpr_warning is True
|
||||||
|
assert report.warning_count == len(report.warnings)
|
||||||
|
assert report.by_maturity["unknown"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_factories_register_expected_prefixes() -> None:
|
||||||
|
async def get_session():
|
||||||
|
raise AssertionError("router construction should not resolve sessions")
|
||||||
|
|
||||||
|
routers = [
|
||||||
|
create_capabilities_router(get_session),
|
||||||
|
create_domains_router(get_session),
|
||||||
|
create_messages_router(get_session),
|
||||||
|
create_repos_router(get_session),
|
||||||
|
create_progress_router(get_session),
|
||||||
|
create_tpsc_router(get_session),
|
||||||
|
create_policy_router(lambda name: None),
|
||||||
|
]
|
||||||
|
|
||||||
|
assert [router.prefix for router in routers] == [
|
||||||
|
"",
|
||||||
|
"/domains",
|
||||||
|
"/messages",
|
||||||
|
"/repos",
|
||||||
|
"/progress",
|
||||||
|
"/tpsc",
|
||||||
|
"/policy",
|
||||||
|
]
|
||||||
|
assert all(router.routes for router in routers)
|
||||||
|
|
||||||
|
|
||||||
|
def test_messages_router_accepts_host_model_injection() -> None:
|
||||||
|
async def get_session():
|
||||||
|
raise AssertionError("router construction should not resolve sessions")
|
||||||
|
|
||||||
|
router = create_messages_router(get_session, message_model=AgentMessage)
|
||||||
|
|
||||||
|
assert router.prefix == "/messages"
|
||||||
|
assert router.routes
|
||||||
|
|
||||||
|
|
||||||
|
def test_domains_router_accepts_host_callbacks_and_schema_injection() -> None:
|
||||||
|
async def get_session():
|
||||||
|
raise AssertionError("router construction should not resolve sessions")
|
||||||
|
|
||||||
|
async def detail_builder(domain, session):
|
||||||
|
raise AssertionError("router construction should not build details")
|
||||||
|
|
||||||
|
async def before_archive(domain, session):
|
||||||
|
raise AssertionError("router construction should not validate archive")
|
||||||
|
|
||||||
|
router = create_domains_router(
|
||||||
|
get_session,
|
||||||
|
domain_model=Domain,
|
||||||
|
repo_model=ManagedRepo,
|
||||||
|
domain_create_schema=DomainCreate,
|
||||||
|
domain_detail_schema=DomainDetail,
|
||||||
|
domain_read_schema=DomainRead,
|
||||||
|
domain_rename_schema=DomainRename,
|
||||||
|
domain_update_schema=DomainUpdate,
|
||||||
|
repo_stub_schema=RepoStub,
|
||||||
|
detail_builder=detail_builder,
|
||||||
|
before_archive=before_archive,
|
||||||
|
include_update_route=False,
|
||||||
|
)
|
||||||
|
method_paths = {
|
||||||
|
(method, route.path)
|
||||||
|
for route in router.routes
|
||||||
|
for method in getattr(route, "methods", set())
|
||||||
|
}
|
||||||
|
|
||||||
|
assert router.prefix == "/domains"
|
||||||
|
assert ("PATCH", "/domains/{slug}") not in method_paths
|
||||||
|
assert ("PATCH", "/domains/{slug}/rename") in method_paths
|
||||||
|
assert ("PATCH", "/domains/{slug}/archive") in method_paths
|
||||||
|
|
||||||
|
|
||||||
|
def test_repos_router_accepts_host_model_and_schema_injection() -> None:
|
||||||
|
async def get_session():
|
||||||
|
raise AssertionError("router construction should not resolve sessions")
|
||||||
|
|
||||||
|
def after_register(repo, body, domain):
|
||||||
|
raise AssertionError("router construction should not call hooks")
|
||||||
|
|
||||||
|
router = create_repos_router(
|
||||||
|
get_session,
|
||||||
|
prefix="",
|
||||||
|
domain_model=Domain,
|
||||||
|
repo_model=ManagedRepo,
|
||||||
|
repo_create_schema=RepoCreate,
|
||||||
|
repo_update_schema=RepoUpdate,
|
||||||
|
repo_read_schema=RepoRead,
|
||||||
|
repo_path_register_schema=RepoPathRegister,
|
||||||
|
list_noload_fields=("domain",),
|
||||||
|
create_extension_fields=("topic_id",),
|
||||||
|
after_register=after_register,
|
||||||
|
include_slug_routes=False,
|
||||||
|
)
|
||||||
|
method_paths = {
|
||||||
|
(method, route.path)
|
||||||
|
for route in router.routes
|
||||||
|
for method in getattr(route, "methods", set())
|
||||||
|
}
|
||||||
|
|
||||||
|
assert router.prefix == ""
|
||||||
|
assert method_paths == {
|
||||||
|
("GET", "/"),
|
||||||
|
("POST", "/"),
|
||||||
|
("GET", "/by-fingerprint"),
|
||||||
|
("GET", "/by-remote"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_repos_router_can_register_only_slug_routes() -> None:
|
||||||
|
async def get_session():
|
||||||
|
raise AssertionError("router construction should not resolve sessions")
|
||||||
|
|
||||||
|
router = create_repos_router(
|
||||||
|
get_session,
|
||||||
|
prefix="",
|
||||||
|
include_collection_routes=False,
|
||||||
|
include_lookup_routes=False,
|
||||||
|
)
|
||||||
|
method_paths = {
|
||||||
|
(method, route.path)
|
||||||
|
for route in router.routes
|
||||||
|
for method in getattr(route, "methods", set())
|
||||||
|
}
|
||||||
|
|
||||||
|
assert method_paths == {
|
||||||
|
("GET", "/{slug}"),
|
||||||
|
("PATCH", "/{slug}"),
|
||||||
|
("POST", "/{slug}/paths"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_capability_catalog_router_accepts_host_model_injection() -> None:
|
||||||
|
async def get_session():
|
||||||
|
raise AssertionError("router construction should not resolve sessions")
|
||||||
|
|
||||||
|
router = create_capability_catalog_router(
|
||||||
|
get_session,
|
||||||
|
domain_model=Domain,
|
||||||
|
repo_model=ManagedRepo,
|
||||||
|
catalog_model=CapabilityCatalog,
|
||||||
|
catalog_create_schema=CatalogCreate,
|
||||||
|
catalog_patch_schema=CatalogPatch,
|
||||||
|
catalog_read_schema=CatalogRead,
|
||||||
|
)
|
||||||
|
method_paths = {
|
||||||
|
(method, route.path)
|
||||||
|
for route in router.routes
|
||||||
|
for method in getattr(route, "methods", set())
|
||||||
|
}
|
||||||
|
|
||||||
|
assert router.prefix == ""
|
||||||
|
assert method_paths == {
|
||||||
|
("GET", "/capability-catalog/"),
|
||||||
|
("POST", "/capability-catalog/"),
|
||||||
|
("PATCH", "/capability-catalog/{entry_id}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_capability_request_read_router_accepts_host_model_injection() -> None:
|
||||||
|
async def get_session():
|
||||||
|
raise AssertionError("router construction should not resolve sessions")
|
||||||
|
|
||||||
|
router = create_capability_request_read_router(
|
||||||
|
get_session,
|
||||||
|
domain_model=Domain,
|
||||||
|
request_model=CapabilityRequest,
|
||||||
|
request_read_schema=CapabilityRequestRead,
|
||||||
|
)
|
||||||
|
method_paths = {
|
||||||
|
(method, route.path)
|
||||||
|
for route in router.routes
|
||||||
|
for method in getattr(route, "methods", set())
|
||||||
|
}
|
||||||
|
|
||||||
|
assert router.prefix == ""
|
||||||
|
assert method_paths == {
|
||||||
|
("GET", "/capability-requests/"),
|
||||||
|
("GET", "/capability-requests/{request_id}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_tpsc_router_accepts_host_model_injection() -> None:
|
||||||
|
async def get_session():
|
||||||
|
raise AssertionError("router construction should not resolve sessions")
|
||||||
|
|
||||||
|
router = create_tpsc_router(
|
||||||
|
get_session,
|
||||||
|
repo_model=ManagedRepo,
|
||||||
|
catalog_model=TPSCCatalog,
|
||||||
|
snapshot_model=TPSCSnapshot,
|
||||||
|
entry_model=TPSCEntry,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert router.prefix == "/tpsc"
|
||||||
|
assert router.routes
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_router_accepts_host_model_and_schema_injection() -> None:
|
||||||
|
async def get_session():
|
||||||
|
raise AssertionError("router construction should not resolve sessions")
|
||||||
|
|
||||||
|
router = create_progress_router(
|
||||||
|
get_session,
|
||||||
|
progress_model=ProgressEvent,
|
||||||
|
progress_create_schema=ProgressEventCreate,
|
||||||
|
progress_read_schema=ProgressEventRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert router.prefix == "/progress"
|
||||||
|
assert router.routes
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_router_can_register_update_route() -> None:
|
||||||
|
router = create_policy_router(
|
||||||
|
lambda name: None,
|
||||||
|
update_policy=lambda name, content: PolicyRead(name=name, content=content),
|
||||||
|
)
|
||||||
|
method_paths = {
|
||||||
|
(method, route.path)
|
||||||
|
for route in router.routes
|
||||||
|
for method in getattr(route, "methods", set())
|
||||||
|
}
|
||||||
|
|
||||||
|
assert ("GET", "/policy/{name}") in method_paths
|
||||||
|
assert ("PUT", "/policy/{name}") in method_paths
|
||||||
|
|
||||||
|
|
||||||
|
def test_fos10_event_contract() -> None:
|
||||||
|
assert RISK_EVENT_TYPES == {
|
||||||
|
"risk_surfaced",
|
||||||
|
"risk_mitigated",
|
||||||
|
"risk_escalated",
|
||||||
|
}
|
||||||
|
assert ALERT_EVENT_TYPES == {
|
||||||
|
"alert_raised",
|
||||||
|
"alert_acknowledged",
|
||||||
|
"alert_resolved",
|
||||||
|
}
|
||||||
|
assert FOS10_EVENT_TYPES == RISK_EVENT_TYPES | ALERT_EVENT_TYPES
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_router_registers_fos10_views() -> None:
|
||||||
|
async def get_session():
|
||||||
|
raise AssertionError("router construction should not resolve sessions")
|
||||||
|
|
||||||
|
router = create_progress_router(get_session)
|
||||||
|
paths = {route.path for route in router.routes}
|
||||||
|
|
||||||
|
assert "/progress/risks" in paths
|
||||||
|
assert "/progress/alerts" in paths
|
||||||
31
tests/test_mcp.py
Normal file
31
tests/test_mcp.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from hub_core.mcp import HubCoreMCPServer
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_base_server_constructs_without_registering_tools() -> None:
|
||||||
|
server = HubCoreMCPServer(
|
||||||
|
name="test-hub",
|
||||||
|
api_base="http://127.0.0.1:9999/",
|
||||||
|
register_tools=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert server.api_base == "http://127.0.0.1:9999"
|
||||||
|
assert server.mcp.name == "test-hub"
|
||||||
|
assert server._clean({"a": None, "b": 1}) == {"b": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_base_server_registers_orientation_doi_and_fos10_tools() -> None:
|
||||||
|
server = HubCoreMCPServer(name="test-hub", api_base="http://127.0.0.1:9999")
|
||||||
|
|
||||||
|
tools = asyncio.run(server.mcp.list_tools())
|
||||||
|
names = {tool.name for tool in tools}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
"get_state_summary",
|
||||||
|
"get_domain_summary",
|
||||||
|
"check_repo_doi",
|
||||||
|
"get_doi_summary",
|
||||||
|
"get_risks",
|
||||||
|
"get_alerts",
|
||||||
|
} <= names
|
||||||
45
tests/test_utils.py
Normal file
45
tests/test_utils.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from hub_core.models.domain import Domain
|
||||||
|
from hub_core.utils import PageParams, apply_pagination, normalize_trailing_slash, resolve_repo_path, slugify
|
||||||
|
|
||||||
|
|
||||||
|
class RepoStub:
|
||||||
|
local_path = "/fallback/path"
|
||||||
|
host_paths = {"workstation": "/host/path"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_slugify_normalizes_text() -> None:
|
||||||
|
assert slugify(" The Custodian: Hub Core! ") == "the-custodian-hub-core"
|
||||||
|
|
||||||
|
|
||||||
|
def test_slugify_rejects_empty_slug() -> None:
|
||||||
|
with pytest.raises(ValueError, match="slug cannot be empty"):
|
||||||
|
slugify(" !!! ")
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_params_bounds() -> None:
|
||||||
|
assert PageParams(limit=10, offset=20).limit == 10
|
||||||
|
with pytest.raises(ValueError, match="limit"):
|
||||||
|
PageParams(limit=0)
|
||||||
|
with pytest.raises(ValueError, match="offset"):
|
||||||
|
PageParams(offset=-1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_pagination_sets_limit_and_offset() -> None:
|
||||||
|
query = apply_pagination(select(Domain), PageParams(limit=25, offset=50))
|
||||||
|
compiled = str(query.compile(compile_kwargs={"literal_binds": True}))
|
||||||
|
assert "LIMIT 25" in compiled
|
||||||
|
assert "OFFSET 50" in compiled
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_repo_path_prefers_host_path() -> None:
|
||||||
|
repo = RepoStub()
|
||||||
|
assert resolve_repo_path(repo, "workstation") == "/host/path"
|
||||||
|
assert resolve_repo_path(repo, "unknown-host") == "/fallback/path"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_trailing_slash_preserves_query_and_fragment() -> None:
|
||||||
|
assert normalize_trailing_slash("/repos?status=active#top") == "/repos/?status=active#top"
|
||||||
|
assert normalize_trailing_slash("/repos/?status=active", trailing=False) == "/repos?status=active"
|
||||||
54
workplans/HUB-WP-0001-statehub-bootstrap.md
Normal file
54
workplans/HUB-WP-0001-statehub-bootstrap.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
id: HUB-WP-0001
|
||||||
|
type: workplan
|
||||||
|
title: "Bootstrap State Hub integration"
|
||||||
|
domain: inter_hub
|
||||||
|
repo: hub-core
|
||||||
|
status: ready
|
||||||
|
owner: codex
|
||||||
|
topic_slug: inter_hub
|
||||||
|
created: "2026-06-16"
|
||||||
|
updated: "2026-06-16"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bootstrap State Hub integration
|
||||||
|
|
||||||
|
**Updated:** 2026-06-16.
|
||||||
|
|
||||||
|
## Review Generated Integration Files
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: HUB-WP-0001-T01
|
||||||
|
status: todo
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Review `INTENT.md`, `SCOPE.md`, `AGENTS.md`, and `.custodian-brief.md`.
|
||||||
|
Replace generated placeholders with repo-specific facts where needed.
|
||||||
|
|
||||||
|
## Verify Local Developer Workflow
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: HUB-WP-0001-T02
|
||||||
|
status: todo
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Identify the repo's install, test, lint, build, and run commands. Add or refine
|
||||||
|
those commands in the agent instructions so future coding sessions can verify
|
||||||
|
changes confidently.
|
||||||
|
|
||||||
|
## Seed First Real Workplan
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: HUB-WP-0001-T03
|
||||||
|
status: todo
|
||||||
|
priority: medium
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the first implementation workplan for the repository's most important
|
||||||
|
next change. After workplan file updates, run from `~/state-hub`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make fix-consistency REPO=hub-core
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user