feat(state-hub): Interface Change Registry (CUST-WP-0033 T01-T06)
Adds first-class tracking for API and interface mutations across the
agent ecosystem. Breaking changes are documented, affected repos are
notified via inbox, and agents discover pending changes at session
start via the dispatch endpoint.
- Migration q4l5m6n7o8p9: interface_changes table
- Model/schema: InterfaceChange with draft→published→resolved lifecycle
- Router: POST/GET/PATCH /interface-changes/, /publish, /resolve actions
(auto-notify affected repo agents on publish; progress event on origin)
- Dispatch: GET /repos/{slug}/dispatch now returns pending_interface_changes
- MCP tools: register_interface_change, list_interface_changes,
publish_interface_change, resolve_interface_change
- Dashboard: /interface-changes page with type badges, planned calendar,
published cards, and draft table
- EP-CUST-ICR-001 registered: webhook subscriptions (deliberately deferred)
First record: trailing-slash normalisation (2026-04-26), published,
affecting repo-registry — visible in repo-registry dispatch immediately.
223 tests passing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ from api.database import engine
|
||||
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
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -51,6 +52,7 @@ 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)
|
||||
app.include_router(state.router)
|
||||
app.include_router(policy.router)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from api.models.capability_request import CapabilityRequest
|
||||
from api.models.tpsc import TPSCCatalog, TPSCSnapshot, TPSCEntry
|
||||
from api.models.doi_cache import DOICache
|
||||
from api.models.token_event import TokenEvent
|
||||
from api.models.interface_change import InterfaceChange
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
@@ -44,4 +45,5 @@ __all__ = [
|
||||
"TPSCCatalog", "TPSCSnapshot", "TPSCEntry",
|
||||
"DOICache",
|
||||
"TokenEvent",
|
||||
"InterfaceChange",
|
||||
]
|
||||
|
||||
53
state-hub/api/models/interface_change.py
Normal file
53
state-hub/api/models/interface_change.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Index, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class InterfaceChange(Base, TimestampMixin):
|
||||
__tablename__ = "interface_changes"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True,
|
||||
)
|
||||
interface_type: Mapped[str] = mapped_column(
|
||||
String(40), nullable=False
|
||||
) # rest_api | mcp_tool | cli | schema | capability
|
||||
change_type: Mapped[str] = mapped_column(
|
||||
String(40), nullable=False
|
||||
) # breaking | additive | deprecation | removal
|
||||
title: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
affected_paths: Mapped[list] = mapped_column(
|
||||
JSONB, nullable=False, default=list, server_default="[]"
|
||||
)
|
||||
affected_repo_slugs: Mapped[list] = mapped_column(
|
||||
JSONB, nullable=False, default=list, server_default="[]"
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="draft", index=True
|
||||
) # draft | published | resolved
|
||||
planned_for: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
published_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
author: Mapped[str] = mapped_column(String(100), nullable=False, default="custodian")
|
||||
|
||||
repo: Mapped["ManagedRepo"] = relationship( # noqa: F821
|
||||
"ManagedRepo", lazy="selectin"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_interface_changes_repo_status", "repo_id", "status"),
|
||||
)
|
||||
192
state-hub/api/routers/interface_changes.py
Normal file
192
state-hub/api/routers/interface_changes.py
Normal file
@@ -0,0 +1,192 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.agent_message import AgentMessage
|
||||
from api.models.interface_change import InterfaceChange
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.progress_event import ProgressEvent
|
||||
from api.schemas.interface_change import (
|
||||
InterfaceChangeCreate,
|
||||
InterfaceChangePatch,
|
||||
InterfaceChangeRead,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/interface-changes", tags=["interface-changes"])
|
||||
|
||||
_VALID_INTERFACE_TYPES = {"rest_api", "mcp_tool", "cli", "schema", "capability"}
|
||||
_VALID_CHANGE_TYPES = {"breaking", "additive", "deprecation", "removal"}
|
||||
|
||||
|
||||
@router.post("/", response_model=InterfaceChangeRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_interface_change(
|
||||
body: InterfaceChangeCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InterfaceChangeRead:
|
||||
if body.interface_type not in _VALID_INTERFACE_TYPES:
|
||||
raise HTTPException(status_code=422, detail=f"interface_type must be one of {sorted(_VALID_INTERFACE_TYPES)}")
|
||||
if body.change_type not in _VALID_CHANGE_TYPES:
|
||||
raise HTTPException(status_code=422, detail=f"change_type must be one of {sorted(_VALID_CHANGE_TYPES)}")
|
||||
|
||||
repo = await _repo_by_slug(body.repo_slug, session)
|
||||
change = InterfaceChange(
|
||||
repo_id=repo.id,
|
||||
interface_type=body.interface_type,
|
||||
change_type=body.change_type,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
affected_paths=body.affected_paths,
|
||||
affected_repo_slugs=body.affected_repo_slugs,
|
||||
planned_for=body.planned_for,
|
||||
author=body.author,
|
||||
status="draft",
|
||||
)
|
||||
session.add(change)
|
||||
await session.commit()
|
||||
await session.refresh(change)
|
||||
return InterfaceChangeRead.from_orm_with_slug(change)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[InterfaceChangeRead])
|
||||
async def list_interface_changes(
|
||||
repo_slug: str | None = Query(None),
|
||||
status: str | None = Query(None),
|
||||
change_type: str | None = Query(None),
|
||||
affected_repo: str | None = Query(None, description="Return changes that affect this repo slug"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[InterfaceChangeRead]:
|
||||
q = select(InterfaceChange).order_by(InterfaceChange.created_at.desc())
|
||||
if repo_slug:
|
||||
repo = await _repo_by_slug(repo_slug, session)
|
||||
q = q.where(InterfaceChange.repo_id == repo.id)
|
||||
if status:
|
||||
q = q.where(InterfaceChange.status == status)
|
||||
if change_type:
|
||||
q = q.where(InterfaceChange.change_type == change_type)
|
||||
if affected_repo:
|
||||
q = q.where(InterfaceChange.affected_repo_slugs.contains([affected_repo]))
|
||||
result = await session.execute(q)
|
||||
return [InterfaceChangeRead.from_orm_with_slug(c) for c in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/{change_id}", response_model=InterfaceChangeRead)
|
||||
async def get_interface_change(
|
||||
change_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InterfaceChangeRead:
|
||||
change = await _get_or_404(change_id, session)
|
||||
return InterfaceChangeRead.from_orm_with_slug(change)
|
||||
|
||||
|
||||
@router.patch("/{change_id}", response_model=InterfaceChangeRead)
|
||||
async def patch_interface_change(
|
||||
change_id: uuid.UUID,
|
||||
body: InterfaceChangePatch,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InterfaceChangeRead:
|
||||
change = await _get_or_404(change_id, session)
|
||||
if change.status != "draft":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Cannot edit a change with status '{change.status}'. Only draft records are mutable.",
|
||||
)
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(change, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(change)
|
||||
return InterfaceChangeRead.from_orm_with_slug(change)
|
||||
|
||||
|
||||
@router.post("/{change_id}/publish", response_model=InterfaceChangeRead)
|
||||
async def publish_interface_change(
|
||||
change_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InterfaceChangeRead:
|
||||
change = await _get_or_404(change_id, session)
|
||||
if change.status != "draft":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Cannot publish a change with status '{change.status}'. Must be 'draft'.",
|
||||
)
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
change.status = "published"
|
||||
change.published_at = now
|
||||
|
||||
# Send inbox notifications to agents of affected repos
|
||||
affected = change.affected_repo_slugs or []
|
||||
for slug in affected:
|
||||
paths_summary = ", ".join(change.affected_paths[:5]) if change.affected_paths else "see description"
|
||||
if len(change.affected_paths) > 5:
|
||||
paths_summary += f" (+{len(change.affected_paths) - 5} more)"
|
||||
msg = AgentMessage(
|
||||
from_agent=change.repo.slug,
|
||||
to_agent=slug,
|
||||
subject=f"[{change.change_type.upper()}] {change.title}",
|
||||
body=(
|
||||
f"**Interface change published by `{change.repo.slug}`**\n\n"
|
||||
f"- Type: `{change.interface_type}` / `{change.change_type}`\n"
|
||||
f"- Affected paths: {paths_summary}\n\n"
|
||||
f"{change.description}\n\n"
|
||||
f"Change ID: `{change.id}` — resolve with "
|
||||
f"`POST /interface-changes/{change.id}/resolve` once adapted."
|
||||
),
|
||||
)
|
||||
session.add(msg)
|
||||
|
||||
# Progress event on the originating repo
|
||||
session.add(ProgressEvent(
|
||||
event_type="milestone",
|
||||
summary=f"Interface change published: {change.title}",
|
||||
detail={
|
||||
"change_id": str(change.id),
|
||||
"change_type": change.change_type,
|
||||
"interface_type": change.interface_type,
|
||||
"affected_repos": affected,
|
||||
"notifications_sent": len(affected),
|
||||
},
|
||||
author=change.author,
|
||||
))
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(change)
|
||||
return InterfaceChangeRead.from_orm_with_slug(change)
|
||||
|
||||
|
||||
@router.post("/{change_id}/resolve", response_model=InterfaceChangeRead)
|
||||
async def resolve_interface_change(
|
||||
change_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InterfaceChangeRead:
|
||||
change = await _get_or_404(change_id, session)
|
||||
if change.status != "published":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Cannot resolve a change with status '{change.status}'. Must be 'published'.",
|
||||
)
|
||||
change.status = "resolved"
|
||||
change.resolved_at = datetime.now(tz=timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(change)
|
||||
return InterfaceChangeRead.from_orm_with_slug(change)
|
||||
|
||||
|
||||
async def _repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo:
|
||||
result = await session.execute(select(ManagedRepo).where(ManagedRepo.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 _get_or_404(change_id: uuid.UUID, session: AsyncSession) -> InterfaceChange:
|
||||
result = await session.execute(
|
||||
select(InterfaceChange).where(InterfaceChange.id == change_id)
|
||||
)
|
||||
change = result.scalar_one_or_none()
|
||||
if change is None:
|
||||
raise HTTPException(status_code=404, detail=f"InterfaceChange '{change_id}' not found")
|
||||
return change
|
||||
@@ -16,6 +16,7 @@ from api.database import get_session
|
||||
from api.doi_engine import compute_fingerprint, evaluate as _doi_evaluate
|
||||
from api.models.doi_cache import DOICache
|
||||
from api.models.domain import Domain
|
||||
from api.models.interface_change import InterfaceChange
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.repo_goal import RepoGoal
|
||||
from api.models.tpsc import TPSCSnapshot
|
||||
@@ -25,6 +26,7 @@ from api.schemas.doi import DoICriterion, DoIReport, DoISummaryEntry
|
||||
from api.schemas.managed_repo import (
|
||||
DispatchTask,
|
||||
DispatchWorkstream,
|
||||
PendingInterfaceChange,
|
||||
RepoCreate,
|
||||
RepoDispatch,
|
||||
RepoPathRegister,
|
||||
@@ -468,11 +470,33 @@ async def get_repo_dispatch(
|
||||
)
|
||||
)
|
||||
|
||||
# Published interface changes that affect this repo and are not yet resolved
|
||||
ic_result = await session.execute(
|
||||
select(InterfaceChange).where(
|
||||
InterfaceChange.status == "published",
|
||||
InterfaceChange.affected_repo_slugs.contains([slug]),
|
||||
).order_by(InterfaceChange.published_at.desc())
|
||||
)
|
||||
pending_changes = [
|
||||
PendingInterfaceChange(
|
||||
id=ic.id,
|
||||
title=ic.title,
|
||||
change_type=ic.change_type,
|
||||
interface_type=ic.interface_type,
|
||||
origin_repo_slug=ic.repo.slug,
|
||||
affected_paths=ic.affected_paths or [],
|
||||
planned_for=ic.planned_for,
|
||||
published_at=ic.published_at,
|
||||
)
|
||||
for ic in ic_result.scalars().all()
|
||||
]
|
||||
|
||||
return RepoDispatch(
|
||||
repo_slug=slug,
|
||||
active_goal=active_goal,
|
||||
active_workstreams=dispatch_workstreams,
|
||||
human_interventions=all_interventions,
|
||||
pending_interface_changes=pending_changes,
|
||||
last_state_synced_at=repo.last_state_synced_at,
|
||||
)
|
||||
|
||||
|
||||
66
state-hub/api/schemas/interface_change.py
Normal file
66
state-hub/api/schemas/interface_change.py
Normal file
@@ -0,0 +1,66 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class InterfaceChangeCreate(BaseModel):
|
||||
repo_slug: str
|
||||
interface_type: str # rest_api | mcp_tool | cli | schema | capability
|
||||
change_type: str # breaking | additive | deprecation | removal
|
||||
title: str
|
||||
description: str
|
||||
affected_paths: list[str] = []
|
||||
affected_repo_slugs: list[str] = []
|
||||
planned_for: date | None = None
|
||||
author: str = "custodian"
|
||||
|
||||
|
||||
class InterfaceChangePatch(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
affected_paths: list[str] | None = None
|
||||
affected_repo_slugs: list[str] | None = None
|
||||
planned_for: date | None = None
|
||||
|
||||
|
||||
class InterfaceChangeRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
repo_id: uuid.UUID
|
||||
repo_slug: str
|
||||
interface_type: str
|
||||
change_type: str
|
||||
title: str
|
||||
description: str
|
||||
affected_paths: list[str]
|
||||
affected_repo_slugs: list[str]
|
||||
status: str
|
||||
planned_for: date | None
|
||||
published_at: datetime | None
|
||||
resolved_at: datetime | None
|
||||
author: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@classmethod
|
||||
def from_orm_with_slug(cls, obj) -> "InterfaceChangeRead":
|
||||
return cls(
|
||||
id=obj.id,
|
||||
repo_id=obj.repo_id,
|
||||
repo_slug=obj.repo.slug,
|
||||
interface_type=obj.interface_type,
|
||||
change_type=obj.change_type,
|
||||
title=obj.title,
|
||||
description=obj.description,
|
||||
affected_paths=obj.affected_paths or [],
|
||||
affected_repo_slugs=obj.affected_repo_slugs or [],
|
||||
status=obj.status,
|
||||
planned_for=obj.planned_for,
|
||||
published_at=obj.published_at,
|
||||
resolved_at=obj.resolved_at,
|
||||
author=obj.author,
|
||||
created_at=obj.created_at,
|
||||
updated_at=obj.updated_at,
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
@@ -68,9 +68,21 @@ class DispatchWorkstream(BaseModel):
|
||||
pending_tasks: list[DispatchTask]
|
||||
|
||||
|
||||
class PendingInterfaceChange(BaseModel):
|
||||
id: uuid.UUID
|
||||
title: str
|
||||
change_type: str
|
||||
interface_type: str
|
||||
origin_repo_slug: str
|
||||
affected_paths: list[str]
|
||||
planned_for: date | None
|
||||
published_at: datetime | None
|
||||
|
||||
|
||||
class RepoDispatch(BaseModel):
|
||||
repo_slug: str
|
||||
active_goal: dict[str, Any] | None
|
||||
active_workstreams: list[DispatchWorkstream]
|
||||
human_interventions: list[DispatchTask]
|
||||
pending_interface_changes: list[PendingInterfaceChange]
|
||||
last_state_synced_at: datetime | None
|
||||
|
||||
@@ -56,9 +56,10 @@ export default {
|
||||
collapsible: true,
|
||||
open: false,
|
||||
pages: [
|
||||
{ name: "Decisions", path: "/decisions" },
|
||||
{ name: "Dependencies", path: "/dependencies" },
|
||||
{ name: "Extends", path: "/extensions" },
|
||||
{ name: "Decisions", path: "/decisions" },
|
||||
{ name: "Dependencies", path: "/dependencies" },
|
||||
{ name: "Extends", path: "/extensions" },
|
||||
{ name: "Interface Changes", path: "/interface-changes" },
|
||||
{ name: "Interventions", path: "/interventions" },
|
||||
{ name: "Tasks", path: "/tasks" },
|
||||
{ name: "UI Feedback", path: "/ui-feedback" },
|
||||
|
||||
154
state-hub/dashboard/src/interface-changes.md
Normal file
154
state-hub/dashboard/src/interface-changes.md
Normal file
@@ -0,0 +1,154 @@
|
||||
---
|
||||
title: Interface Changes
|
||||
---
|
||||
|
||||
```js
|
||||
import {API} from "./components/config.js";
|
||||
```
|
||||
|
||||
```js
|
||||
const [published, draft, repos] = await Promise.all([
|
||||
fetch(`${API}/interface-changes/?status=published`).then(r => r.ok ? r.json() : []),
|
||||
fetch(`${API}/interface-changes/?status=draft`).then(r => r.ok ? r.json() : []),
|
||||
fetch(`${API}/repos/`).then(r => r.ok ? r.json() : []),
|
||||
]);
|
||||
```
|
||||
|
||||
```js
|
||||
const repoMap = Object.fromEntries(repos.map(r => [r.slug, r]));
|
||||
|
||||
const CHANGE_TYPE_COLOR = {
|
||||
breaking: "#ef4444",
|
||||
removal: "#f97316",
|
||||
deprecation: "#f59e0b",
|
||||
additive: "#22c55e",
|
||||
};
|
||||
|
||||
function changeBadge(type) {
|
||||
const color = CHANGE_TYPE_COLOR[type] ?? "#6b7280";
|
||||
return htl.html`<span style="
|
||||
background:${color};color:#fff;font-size:11px;font-weight:700;
|
||||
padding:2px 7px;border-radius:10px;text-transform:uppercase;letter-spacing:.04em
|
||||
">${type}</span>`;
|
||||
}
|
||||
|
||||
function interfaceBadge(type) {
|
||||
return htl.html`<span style="
|
||||
background:#3b82f6;color:#fff;font-size:10px;font-weight:600;
|
||||
padding:1px 6px;border-radius:8px
|
||||
">${type}</span>`;
|
||||
}
|
||||
```
|
||||
|
||||
# Interface Changes
|
||||
|
||||
Track breaking and additive mutations to published interfaces across repos.
|
||||
Publishing a change sends inbox notifications to all affected agents.
|
||||
|
||||
<div style="display:flex;gap:16px;margin-bottom:24px">
|
||||
<div style="background:#fef2f2;border:1px solid #fca5a5;border-radius:8px;padding:12px 20px;text-align:center">
|
||||
<div style="font-size:28px;font-weight:700;color:#dc2626">${published.filter(c => c.change_type === "breaking").length}</div>
|
||||
<div style="font-size:12px;color:#6b7280">Breaking (live)</div>
|
||||
</div>
|
||||
<div style="background:#fff7ed;border:1px solid #fdba74;border-radius:8px;padding:12px 20px;text-align:center">
|
||||
<div style="font-size:28px;font-weight:700;color:#ea580c">${published.filter(c => c.change_type === "deprecation").length}</div>
|
||||
<div style="font-size:12px;color:#6b7280">Deprecations (live)</div>
|
||||
</div>
|
||||
<div style="background:#f0fdf4;border:1px solid #86efac;border-radius:8px;padding:12px 20px;text-align:center">
|
||||
<div style="font-size:28px;font-weight:700;color:#16a34a">${published.filter(c => c.change_type === "additive").length}</div>
|
||||
<div style="font-size:12px;color:#6b7280">Additive (live)</div>
|
||||
</div>
|
||||
<div style="background:#f9fafb;border:1px solid #d1d5db;border-radius:8px;padding:12px 20px;text-align:center">
|
||||
<div style="font-size:28px;font-weight:700;color:#374151">${draft.length}</div>
|
||||
<div style="font-size:12px;color:#6b7280">Drafts</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Planned changes
|
||||
|
||||
```js
|
||||
const planned = [...published, ...draft].filter(c => c.planned_for);
|
||||
planned.sort((a, b) => a.planned_for.localeCompare(b.planned_for));
|
||||
```
|
||||
|
||||
```js
|
||||
if (planned.length === 0) {
|
||||
display(htl.html`<p style="color:#6b7280;font-style:italic">No changes with a planned date.</p>`);
|
||||
} else {
|
||||
display(htl.html`<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||||
<thead><tr style="border-bottom:2px solid #e5e7eb">
|
||||
<th style="text-align:left;padding:6px 8px">Date</th>
|
||||
<th style="text-align:left;padding:6px 8px">Change</th>
|
||||
<th style="text-align:left;padding:6px 8px">Repo</th>
|
||||
<th style="text-align:left;padding:6px 8px">Status</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${planned.map(c => htl.html`<tr style="border-bottom:1px solid #f3f4f6">
|
||||
<td style="padding:6px 8px;white-space:nowrap;color:#374151">${c.planned_for}</td>
|
||||
<td style="padding:6px 8px">${changeBadge(c.change_type)} ${c.title}</td>
|
||||
<td style="padding:6px 8px;color:#6b7280">${c.origin_repo_slug ?? c.repo_slug}</td>
|
||||
<td style="padding:6px 8px">${c.status}</td>
|
||||
</tr>`)}
|
||||
</tbody>
|
||||
</table>`);
|
||||
}
|
||||
```
|
||||
|
||||
## Published changes
|
||||
|
||||
```js
|
||||
if (published.length === 0) {
|
||||
display(htl.html`<p style="color:#6b7280;font-style:italic">No published changes.</p>`);
|
||||
} else {
|
||||
display(htl.html`<div style="display:flex;flex-direction:column;gap:12px">
|
||||
${published.map(c => htl.html`
|
||||
<div style="border:1px solid #e5e7eb;border-radius:8px;padding:14px 16px">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;flex-wrap:wrap">
|
||||
${changeBadge(c.change_type)}
|
||||
${interfaceBadge(c.interface_type)}
|
||||
<strong style="font-size:14px">${c.title}</strong>
|
||||
</div>
|
||||
<div style="font-size:12px;color:#6b7280;margin-bottom:6px">
|
||||
Repo: <strong>${c.origin_repo_slug ?? c.repo_slug}</strong>
|
||||
${c.published_at ? htl.html` · Published ${c.published_at.slice(0,10)}` : ""}
|
||||
${c.affected_repo_slugs?.length ? htl.html` · Affects: ${c.affected_repo_slugs.join(", ")}` : ""}
|
||||
</div>
|
||||
<details style="font-size:13px;color:#374151">
|
||||
<summary style="cursor:pointer;color:#6b7280">Description</summary>
|
||||
<pre style="margin-top:6px;white-space:pre-wrap;font-family:inherit">${c.description}</pre>
|
||||
</details>
|
||||
${c.affected_paths?.length ? htl.html`
|
||||
<div style="margin-top:6px;font-size:12px">
|
||||
<strong>Paths:</strong>
|
||||
${c.affected_paths.map(p => htl.html`<code style="background:#f3f4f6;padding:1px 5px;border-radius:4px;margin:0 2px">${p}</code>`)}
|
||||
</div>` : ""}
|
||||
</div>
|
||||
`)}
|
||||
</div>`);
|
||||
}
|
||||
```
|
||||
|
||||
## Drafts
|
||||
|
||||
```js
|
||||
if (draft.length === 0) {
|
||||
display(htl.html`<p style="color:#6b7280;font-style:italic">No draft changes.</p>`);
|
||||
} else {
|
||||
display(htl.html`<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||||
<thead><tr style="border-bottom:2px solid #e5e7eb">
|
||||
<th style="text-align:left;padding:6px 8px">Title</th>
|
||||
<th style="text-align:left;padding:6px 8px">Repo</th>
|
||||
<th style="text-align:left;padding:6px 8px">Type</th>
|
||||
<th style="text-align:left;padding:6px 8px">Affected</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${draft.map(c => htl.html`<tr style="border-bottom:1px solid #f3f4f6">
|
||||
<td style="padding:6px 8px">${c.title}</td>
|
||||
<td style="padding:6px 8px;color:#6b7280">${c.repo_slug}</td>
|
||||
<td style="padding:6px 8px">${changeBadge(c.change_type)} ${interfaceBadge(c.interface_type)}</td>
|
||||
<td style="padding:6px 8px;color:#6b7280">${(c.affected_repo_slugs ?? []).join(", ") || "—"}</td>
|
||||
</tr>`)}
|
||||
</tbody>
|
||||
</table>`);
|
||||
}
|
||||
```
|
||||
@@ -2557,6 +2557,147 @@ def get_token_summary(scope: str, id: str) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interface Change Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
def register_interface_change(
|
||||
repo_slug: str,
|
||||
interface_type: str,
|
||||
change_type: str,
|
||||
title: str,
|
||||
description: str,
|
||||
affected_paths: list[str] | None = None,
|
||||
affected_repo_slugs: list[str] | None = None,
|
||||
planned_for: str | None = None,
|
||||
author: str = "custodian",
|
||||
) -> str:
|
||||
"""Create a draft InterfaceChange record.
|
||||
|
||||
Documents a mutation to a published interface boundary. Records stay in
|
||||
'draft' status until explicitly published via publish_interface_change().
|
||||
|
||||
Args:
|
||||
repo_slug: Slug of the repo that owns the interface.
|
||||
interface_type: One of: rest_api, mcp_tool, cli, schema, capability.
|
||||
change_type: One of: breaking, additive, deprecation, removal.
|
||||
title: Short summary (e.g. 'Remove trailing slash from param routes').
|
||||
description: Full before/after description of what changed.
|
||||
affected_paths: Specific endpoints, tool names, or fields changed.
|
||||
affected_repo_slugs: Repos known to consume this interface.
|
||||
planned_for: ISO date string (YYYY-MM-DD) if change is pre-announced.
|
||||
author: Agent or person creating this record.
|
||||
"""
|
||||
payload = {
|
||||
"repo_slug": repo_slug,
|
||||
"interface_type": interface_type,
|
||||
"change_type": change_type,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"affected_paths": affected_paths or [],
|
||||
"affected_repo_slugs": affected_repo_slugs or [],
|
||||
"author": author,
|
||||
}
|
||||
if planned_for:
|
||||
payload["planned_for"] = planned_for
|
||||
result = _post("/interface-changes/", payload)
|
||||
if isinstance(result, dict) and result.get("id"):
|
||||
return (
|
||||
f"Draft created: {result['title']}\n"
|
||||
f"ID: {result['id']}\n"
|
||||
f"Repo: {result['repo_slug']} / {result['interface_type']} / {result['change_type']}\n"
|
||||
f"Affected repos: {', '.join(result['affected_repo_slugs']) or '(none listed)'}\n"
|
||||
f"Status: draft — call publish_interface_change('{result['id']}') when ready."
|
||||
)
|
||||
return f"Error: {result}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_interface_changes(
|
||||
repo_slug: str | None = None,
|
||||
status: str | None = None,
|
||||
change_type: str | None = None,
|
||||
affected_repo: str | None = None,
|
||||
) -> str:
|
||||
"""List interface change records with optional filters.
|
||||
|
||||
Args:
|
||||
repo_slug: Filter by originating repo.
|
||||
status: Filter by status: draft | published | resolved.
|
||||
change_type: Filter by change type: breaking | additive | deprecation | removal.
|
||||
affected_repo: Return changes that affect this repo slug.
|
||||
"""
|
||||
params: dict = {}
|
||||
if repo_slug:
|
||||
params["repo_slug"] = repo_slug
|
||||
if status:
|
||||
params["status"] = status
|
||||
if change_type:
|
||||
params["change_type"] = change_type
|
||||
if affected_repo:
|
||||
params["affected_repo"] = affected_repo
|
||||
|
||||
results = _get("/interface-changes/", params if params else None)
|
||||
if not isinstance(results, list):
|
||||
return f"Error: {results}"
|
||||
if not results:
|
||||
return "No interface changes found matching the given filters."
|
||||
|
||||
lines = [f"Interface Changes ({len(results)} found):", ""]
|
||||
for r in results:
|
||||
planned = f" [planned {r['planned_for']}]" if r.get("planned_for") else ""
|
||||
pub = f" [published {r['published_at'][:10]}]" if r.get("published_at") else ""
|
||||
affected = ", ".join(r["affected_repo_slugs"]) or "(none)"
|
||||
lines += [
|
||||
f"[{r['status'].upper()}] {r['title']}{planned}{pub}",
|
||||
f" ID: {r['id']}",
|
||||
f" {r['repo_slug']} / {r['interface_type']} / {r['change_type']}",
|
||||
f" Affected: {affected}",
|
||||
"",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def publish_interface_change(change_id: str) -> str:
|
||||
"""Publish a draft InterfaceChange, making it live and notifying affected agents.
|
||||
|
||||
Transitions status draft → published, sets published_at, sends an inbox
|
||||
message to each affected_repo_slug agent, and appends a progress event.
|
||||
|
||||
Args:
|
||||
change_id: UUID of the InterfaceChange to publish.
|
||||
"""
|
||||
result = _post(f"/interface-changes/{change_id}/publish", {})
|
||||
if isinstance(result, dict) and result.get("status") == "published":
|
||||
n = len(result.get("affected_repo_slugs") or [])
|
||||
return (
|
||||
f"Published: {result['title']}\n"
|
||||
f"ID: {result['id']}\n"
|
||||
f"Notifications sent to {n} repo(s): "
|
||||
f"{', '.join(result['affected_repo_slugs']) or '(none)'}\n"
|
||||
f"Resolve with: resolve_interface_change('{result['id']}')"
|
||||
)
|
||||
return f"Error: {result}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def resolve_interface_change(change_id: str) -> str:
|
||||
"""Mark a published InterfaceChange as resolved.
|
||||
|
||||
Call this once all known dependents have adapted. Transitions
|
||||
status published → resolved and sets resolved_at.
|
||||
|
||||
Args:
|
||||
change_id: UUID of the InterfaceChange to resolve.
|
||||
"""
|
||||
result = _post(f"/interface-changes/{change_id}/resolve", {})
|
||||
if isinstance(result, dict) and result.get("status") == "resolved":
|
||||
return f"Resolved: {result['title']} (ID: {result['id']})"
|
||||
return f"Error: {result}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""add interface_changes table
|
||||
|
||||
Revision ID: q4l5m6n7o8p9
|
||||
Revises: p3k4l5m6n7o8
|
||||
Create Date: 2026-04-26
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
|
||||
revision = "q4l5m6n7o8p9"
|
||||
down_revision = "p3k4l5m6n7o8"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"interface_changes",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("repo_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("managed_repos.id", ondelete="CASCADE"),
|
||||
nullable=False),
|
||||
sa.Column("interface_type", sa.String(40), nullable=False),
|
||||
sa.Column("change_type", sa.String(40), nullable=False),
|
||||
sa.Column("title", sa.String(300), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=False),
|
||||
sa.Column("affected_paths", JSONB, nullable=False, server_default="[]"),
|
||||
sa.Column("affected_repo_slugs", JSONB, nullable=False, server_default="[]"),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="draft"),
|
||||
sa.Column("planned_for", sa.Date, nullable=True),
|
||||
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("author", sa.String(100), nullable=False, server_default="custodian"),
|
||||
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_interface_changes_repo_id", "interface_changes", ["repo_id"])
|
||||
op.create_index("ix_interface_changes_status", "interface_changes", ["status"])
|
||||
op.create_index("ix_interface_changes_repo_status", "interface_changes", ["repo_id", "status"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("interface_changes")
|
||||
Reference in New Issue
Block a user