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. + +
No changes with a planned date.
`); +} else { + display(htl.html`| Date | +Change | +Repo | +Status | +
|---|---|---|---|
| ${c.planned_for} | +${changeBadge(c.change_type)} ${c.title} | +${c.origin_repo_slug ?? c.repo_slug} | +${c.status} | +
No published changes.
`); +} else { + display(htl.html`${c.description}
+ ${p}`)}
+ No draft changes.
`); +} else { + display(htl.html`| Title | +Repo | +Type | +Affected | +
|---|---|---|---|
| ${c.title} | +${c.repo_slug} | +${changeBadge(c.change_type)} ${interfaceBadge(c.interface_type)} | +${(c.affected_repo_slugs ?? []).join(", ") || "—"} | +