state-hub/api/main.py

107 lines
3.6 KiB
Python
Raw Normal View History

import hashlib
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response as StarletteResponse
from api.database import engine
from api.events import shutdown_publisher
from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies
from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages, capability_requests, tpsc
from api.routers import token_events
from api.routers import interface_changes
2026-05-02 00:21:14 +02:00
from api.routers import flows
class ETagMiddleware(BaseHTTPMiddleware):
"""Add ETag + conditional-GET (304) support to all JSON GET responses."""
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
if request.method != "GET":
return response
if "application/json" not in response.headers.get("content-type", ""):
return response
body_parts = []
async for chunk in response.body_iterator:
body_parts.append(chunk)
body = b"".join(body_parts)
etag = '"' + hashlib.md5(body, usedforsecurity=False).hexdigest() + '"'
if request.headers.get("if-none-match") == etag:
return StarletteResponse(
status_code=304,
headers={"ETag": etag, "Cache-Control": "no-cache"},
)
headers = {k: v for k, v in response.headers.items() if k.lower() != "content-length"}
headers["ETag"] = etag
if not any(k.lower() == "cache-control" for k in headers):
headers["Cache-Control"] = "no-cache"
return StarletteResponse(
content=body,
status_code=response.status_code,
headers=headers,
media_type=response.media_type,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
await shutdown_publisher()
await engine.dispose()
app = FastAPI(
title="Custodian State Hub",
description="Local-first state API for the Custodian agent system.",
version="0.6.0",
lifespan=lifespan,
)
_cors_env = os.getenv("CORS_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000")
_cors_origins = [o.strip() for o in _cors_env.split(",") if o.strip()]
app.add_middleware(ETagMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=_cors_origins,
allow_methods=["GET", "POST", "PATCH", "DELETE", "PUT"],
allow_headers=["Content-Type", "If-None-Match"],
expose_headers=["ETag"],
)
feat(state-hub): implement v0.5 — dynamic domains & multi-repo Replaces the hardcoded 6-domain PostgreSQL ENUM with a first-class `domains` DB table, and adds a `managed_repos` table for multi-repo support per domain. P1 — Domain as a DB entity: - Migration b1c2d3e4f5a6: creates `domains` table, migrates topics.domain ENUM column to domain_id FK, drops the domain ENUM type - Domain ORM model (api/models/domain.py) + Pydantic schemas - Domain API router: GET/POST /domains/, GET/PATCH /domains/{slug}/, rename and archive endpoints with EP/TD cascade on rename - Topic model updated: domain_id FK + @property domain_slug for backwards-compatible JSON serialization (field renamed domain → domain_slug) - TopicCreate/TopicRead updated; seed.py rewritten to use FK lookup P2 — Multi-repo support: - ManagedRepo ORM model (api/models/managed_repo.py) + schemas - Repo API router: GET/POST /repos/, GET/PATCH /repos/{slug}/, archive - Makefile: add-domain, rename-domain, add-repo, list-repos targets - register_project.sh: verify domain via /domains/ API + POST /repos/ P3 — MCP tools & live validation: - 6 new MCP tools: list_domains, create_domain, rename_domain, archive_domain, list_domain_repos, register_repo - EP/TD routers: replace hardcoded VALID_DOMAINS set with per-request DB lookup — returns 422 with list of valid slugs on unknown domain - State summary: adds domains: list[DomainSummary] (slug, name, repo_count, active_workstream_count, ep_count, td_count) - TOOLS.md updated with domain management section P4 — Dashboard: - New domains.md page with KPI row + domain cards + repo lists - domains.json.py + repos.json.py data loaders - Domains page added to observablehq.config.js nav - workstreams.md, extensions.md, techdept.md: domain_slug fix + dynamic domain list loaded from /domains/ API (no longer hardcoded) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:20:15 +01:00
app.include_router(domains.router)
app.include_router(repos.router)
app.include_router(topics.router)
app.include_router(workstreams.router)
Implement State Hub v0.2: dependency graph, next-steps suggestions, design boundary S0 — Design boundary formalised across all integration surfaces: - TOOLS.md restructured with Design Boundary section, Sanctioned Write Tools, and Bootstrap-Only Tools (create_workstream, create_task) with explicit note - project_claude_md.template and railiance CLAUDE.md updated with boundary note and get_next_steps() in session start protocol - Global ~/.claude/CLAUDE.md updated accordingly S1 — Workstream dependency graph: - WorkstreamDependency model (directed edge, CASCADE on delete, unique pair constraint) - Alembic migration 0b547c153153; script.py.mako added (was missing) - REST API: POST/GET /workstreams/{id}/dependencies/, DELETE …/{dep_id} (hard delete) - StateSummary open_workstreams enriched with depends_on/blocks lists - MCP tools: create_dependency(), list_dependencies() - Dashboard workstreams page: Dependencies section with relationship cards - Seeded: custodian-agent-runtime → llm-shared-library + phase-0-operational-baseline S2 — Suggesting Next Steps (sanctioned write use case #2): - GET /state/next_steps derives suggestions from recently resolved decisions (→ first open task in same workstream) and cleared dependencies (→ first todo task in now-unblocked workstream) - StateSummary.next_steps included on every summary call - MCP tool: get_next_steps() - Dashboard: "What's next?" card grid above Registered Projects Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 23:33:14 +01:00
app.include_router(workstream_dependencies.router)
app.include_router(tasks.router)
app.include_router(decisions.router)
app.include_router(extension_points.router)
app.include_router(technical_debt.router)
app.include_router(progress.router)
app.include_router(domain_goals.router)
app.include_router(repo_goals.router)
app.include_router(contributions.router)
app.include_router(sbom.router)
app.include_router(messages.router)
app.include_router(capability_requests.router)
app.include_router(tpsc.router)
app.include_router(token_events.router)
app.include_router(interface_changes.router)
2026-05-02 00:21:14 +02:00
app.include_router(flows.router)
app.include_router(state.router)
app.include_router(policy.router)
@app.get("/", include_in_schema=False)
async def root():
return {"service": "state-hub", "docs": "/docs"}