feat(mutator,web): live topology mutation pipeline backend (DEBT-030)

Wire the mutator and web API into the service bus so live-topology
edits flow sub-second from enqueue to UI:

- Mutator publishes every state transition on the bus (mutation.applying
  /applied/failed + topology.status). Fire-and-forget; DB stays source
  of truth.
- Mutator watch loop subscribes to topology.*.mutation.enqueued and
  wakes early via asyncio.Event — the 10s poll becomes a fallback
  heartbeat, not the primary dispatch trigger.
- POST /topologies/{id}/mutations publishes mutation.enqueued after
  the DB write succeeds.
- New GET /topologies/{id}/events SSE route: snapshot on connect
  (status + in-flight mutations), live forwards topology.{id}.>
  bus events, 15s keepalive. ?token= auth mirrors /stream.
- New decnet/bus/app.py — process-wide lazy bus singleton for the
  API, closed cleanly on lifespan shutdown.
This commit is contained in:
2026-04-21 14:38:25 -04:00
parent f0349632c3
commit f611e7363b
6 changed files with 347 additions and 3 deletions

View File

@@ -13,6 +13,9 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from decnet.bus import topics as _topics
from decnet.bus.app import get_app_bus
from decnet.logging import get_logger
from decnet.telemetry import traced as _traced
from decnet.topology.status import (
TopologyStatus,
@@ -27,6 +30,8 @@ from decnet.web.dependencies import repo, require_admin, require_viewer
from ._guards import get_topology_or_404, map_repo_exception
_log = get_logger("api.topology.mutations")
router = APIRouter()
_MUTATABLE: frozenset[str] = frozenset(
@@ -80,6 +85,20 @@ async def api_enqueue_mutation(
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# Fire-and-forget bus publish so the mutator can wake immediately and
# the SSE route can notify connected editors. Bus failure here must
# never mask a successful enqueue — the DB row is authoritative.
bus = await get_app_bus()
if bus is not None:
try:
await bus.publish(
_topics.topology_mutation(topology_id, _topics.MUTATION_ENQUEUED),
{"mutation_id": mutation_id, "op": body.op, "payload": body.payload},
event_type=_topics.MUTATION_ENQUEUED,
)
except Exception as exc: # noqa: BLE001
_log.warning("bus publish (enqueued) failed: %s", exc)
return MutationEnqueueResponse(mutation_id=mutation_id, state="pending")