generated from coulomb/repo-seed
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:
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user