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:
2026-04-26 15:29:08 +02:00
parent 768a8ba9c7
commit 548c3efe4a
11 changed files with 696 additions and 4 deletions

View 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>`);
}
```