From 17303d25190dfa2644448a69e971d4706b27d389 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 26 Apr 2026 15:29:08 +0200 Subject: [PATCH] feat(state-hub): Interface Change Registry (CUST-WP-0033 T01-T06) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- state-hub/api/main.py | 2 + state-hub/api/models/__init__.py | 2 + state-hub/api/models/interface_change.py | 53 +++++ state-hub/api/routers/interface_changes.py | 192 ++++++++++++++++++ state-hub/api/routers/repos.py | 24 +++ state-hub/api/schemas/interface_change.py | 66 ++++++ state-hub/api/schemas/managed_repo.py | 14 +- state-hub/dashboard/observablehq.config.js | 7 +- state-hub/dashboard/src/interface-changes.md | 154 ++++++++++++++ state-hub/mcp_server/server.py | 141 +++++++++++++ .../q4l5m6n7o8p9_add_interface_changes.py | 45 ++++ 11 files changed, 696 insertions(+), 4 deletions(-) create mode 100644 state-hub/api/models/interface_change.py create mode 100644 state-hub/api/routers/interface_changes.py create mode 100644 state-hub/api/schemas/interface_change.py create mode 100644 state-hub/dashboard/src/interface-changes.md create mode 100644 state-hub/migrations/versions/q4l5m6n7o8p9_add_interface_changes.py diff --git a/state-hub/api/main.py b/state-hub/api/main.py index 2f83322..8d9a2fd 100644 --- a/state-hub/api/main.py +++ b/state-hub/api/main.py @@ -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) diff --git a/state-hub/api/models/__init__.py b/state-hub/api/models/__init__.py index 5445750..75c424e 100644 --- a/state-hub/api/models/__init__.py +++ b/state-hub/api/models/__init__.py @@ -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", ] diff --git a/state-hub/api/models/interface_change.py b/state-hub/api/models/interface_change.py new file mode 100644 index 0000000..9d6652f --- /dev/null +++ b/state-hub/api/models/interface_change.py @@ -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"), + ) diff --git a/state-hub/api/routers/interface_changes.py b/state-hub/api/routers/interface_changes.py new file mode 100644 index 0000000..d4985a3 --- /dev/null +++ b/state-hub/api/routers/interface_changes.py @@ -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 diff --git a/state-hub/api/routers/repos.py b/state-hub/api/routers/repos.py index e33b2e6..7caf442 100644 --- a/state-hub/api/routers/repos.py +++ b/state-hub/api/routers/repos.py @@ -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, ) diff --git a/state-hub/api/schemas/interface_change.py b/state-hub/api/schemas/interface_change.py new file mode 100644 index 0000000..35c87b8 --- /dev/null +++ b/state-hub/api/schemas/interface_change.py @@ -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, + ) diff --git a/state-hub/api/schemas/managed_repo.py b/state-hub/api/schemas/managed_repo.py index ebda3f8..d30b6bb 100644 --- a/state-hub/api/schemas/managed_repo.py +++ b/state-hub/api/schemas/managed_repo.py @@ -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 diff --git a/state-hub/dashboard/observablehq.config.js b/state-hub/dashboard/observablehq.config.js index 274b840..de71d03 100644 --- a/state-hub/dashboard/observablehq.config.js +++ b/state-hub/dashboard/observablehq.config.js @@ -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" }, diff --git a/state-hub/dashboard/src/interface-changes.md b/state-hub/dashboard/src/interface-changes.md new file mode 100644 index 0000000..f30777f --- /dev/null +++ b/state-hub/dashboard/src/interface-changes.md @@ -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`${type}`; +} + +function interfaceBadge(type) { + return htl.html`${type}`; +} +``` + +# Interface Changes + +Track breaking and additive mutations to published interfaces across repos. +Publishing a change sends inbox notifications to all affected agents. + +
+
+
${published.filter(c => c.change_type === "breaking").length}
+
Breaking (live)
+
+
+
${published.filter(c => c.change_type === "deprecation").length}
+
Deprecations (live)
+
+
+
${published.filter(c => c.change_type === "additive").length}
+
Additive (live)
+
+
+
${draft.length}
+
Drafts
+
+
+ +## 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`

No changes with a planned date.

`); +} else { + display(htl.html` + + + + + + + + ${planned.map(c => htl.html` + + + + + `)} + +
DateChangeRepoStatus
${c.planned_for}${changeBadge(c.change_type)} ${c.title}${c.origin_repo_slug ?? c.repo_slug}${c.status}
`); +} +``` + +## Published changes + +```js +if (published.length === 0) { + display(htl.html`

No published changes.

`); +} else { + display(htl.html`
+ ${published.map(c => htl.html` +
+
+ ${changeBadge(c.change_type)} + ${interfaceBadge(c.interface_type)} + ${c.title} +
+
+ Repo: ${c.origin_repo_slug ?? c.repo_slug} + ${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(", ")}` : ""} +
+
+ Description +
${c.description}
+
+ ${c.affected_paths?.length ? htl.html` +
+ Paths: + ${c.affected_paths.map(p => htl.html`${p}`)} +
` : ""} +
+ `)} +
`); +} +``` + +## Drafts + +```js +if (draft.length === 0) { + display(htl.html`

No draft changes.

`); +} else { + display(htl.html` + + + + + + + + ${draft.map(c => htl.html` + + + + + `)} + +
TitleRepoTypeAffected
${c.title}${c.repo_slug}${changeBadge(c.change_type)} ${interfaceBadge(c.interface_type)}${(c.affected_repo_slugs ?? []).join(", ") || "—"}
`); +} +``` diff --git a/state-hub/mcp_server/server.py b/state-hub/mcp_server/server.py index 0b8bc60..dfa2e14 100644 --- a/state-hub/mcp_server/server.py +++ b/state-hub/mcp_server/server.py @@ -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 # --------------------------------------------------------------------------- diff --git a/state-hub/migrations/versions/q4l5m6n7o8p9_add_interface_changes.py b/state-hub/migrations/versions/q4l5m6n7o8p9_add_interface_changes.py new file mode 100644 index 0000000..b21bdf0 --- /dev/null +++ b/state-hub/migrations/versions/q4l5m6n7o8p9_add_interface_changes.py @@ -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")