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>
This commit is contained in:
2026-02-28 15:20:15 +01:00
parent c3efb099f1
commit fcd0f06536
29 changed files with 1192 additions and 73 deletions

View File

@@ -0,0 +1,141 @@
"""v0.5 — dynamic domains table and managed_repos
Replaces the hardcoded PostgreSQL ENUM `domain` type on the `topics` table
with a first-class `domains` table (FK: topics.domain_id → domains.id).
Also introduces `managed_repos` for multi-repo support per domain.
Revision ID: b1c2d3e4f5a6
Revises: a3f1c2d4e5b6
Create Date: 2026-02-28 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "b1c2d3e4f5a6"
down_revision: Union[str, None] = "a3f1c2d4e5b6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Canonical domain slugs matching the old ENUM values
CANONICAL_DOMAINS = [
("custodian", "The Custodian"),
("railiance", "Railiance"),
("markitect", "Markitect"),
("coulomb_social", "Coulomb.social"),
("personhood", "Personhood"),
("foerster_capabilities", "Foerster Capabilities"),
]
def upgrade() -> None:
# ── Step 1: Create domains table ─────────────────────────────────────────
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"])
# ── Step 2: Insert 6 canonical domain rows ────────────────────────────────
for slug, name in CANONICAL_DOMAINS:
op.execute(sa.text(
"INSERT INTO domains (id, slug, name, status, created_at, updated_at) "
"VALUES (gen_random_uuid(), :slug, :name, 'active', now(), now()) "
"ON CONFLICT (slug) DO NOTHING"
).bindparams(slug=slug, name=name))
# ── Step 3: Add domain_id FK column to topics (nullable initially) ────────
op.add_column(
"topics",
sa.Column("domain_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("domains.id", ondelete="RESTRICT"), nullable=True),
)
# ── Step 4: Populate domain_id from existing enum values ──────────────────
op.execute(sa.text(
"UPDATE topics SET domain_id = ("
" SELECT id FROM domains WHERE domains.slug = topics.domain::text"
")"
))
# ── Step 5: Make domain_id NOT NULL ────────────────────────────────────────
op.alter_column("topics", "domain_id", nullable=False)
# ── Step 6: Drop old domain enum column ───────────────────────────────────
op.drop_column("topics", "domain")
# ── Step 7: Drop PostgreSQL ENUM type ─────────────────────────────────────
op.execute(sa.text("DROP TYPE IF EXISTS domain"))
# ── Step 8: Create managed_repos table ────────────────────────────────────
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("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("topic_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("topics.id", ondelete="SET NULL"), 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_slug", "managed_repos", ["slug"])
op.create_index("ix_managed_repos_domain_id", "managed_repos", ["domain_id"])
def downgrade() -> None:
# ── Drop managed_repos ────────────────────────────────────────────────────
op.drop_table("managed_repos")
# ── Recreate domain ENUM type ─────────────────────────────────────────────
domain_enum = postgresql.ENUM(
"custodian", "railiance", "markitect", "coulomb_social",
"personhood", "foerster_capabilities",
name="domain", create_type=True,
)
domain_enum.create(op.get_bind(), checkfirst=True)
# ── Add domain column back (nullable initially) ───────────────────────────
op.add_column(
"topics",
sa.Column("domain", sa.Enum(
"custodian", "railiance", "markitect", "coulomb_social",
"personhood", "foerster_capabilities",
name="domain", create_type=False,
), nullable=True),
)
# ── Populate from domain_id ────────────────────────────────────────────────
op.execute(sa.text(
"UPDATE topics SET domain = ("
" SELECT slug FROM domains WHERE domains.id = topics.domain_id"
")::domain"
))
# ── Make NOT NULL ──────────────────────────────────────────────────────────
op.alter_column("topics", "domain", nullable=False)
# ── Drop domain_id ─────────────────────────────────────────────────────────
op.drop_column("topics", "domain_id")
# ── Drop domains table ─────────────────────────────────────────────────────
op.drop_table("domains")