Draw every flow as one graph, merged on what it talks to

A node type can now say which outside thing its parameters point at, and
nodes sharing one — a broker topic, a URL, a bucket — are drawn as a single
neuron on a new /brain canvas. That makes the wiring which runs between
flows through a broker visible for the first time; no single flow's canvas
can show it. The key is read off stored parameters, so a credential
reference never reaches an id.

Layout is a d3 force simulation settled once and then frozen, lit by the
socket the editor already listens to: a neuron pulses when any node behind
it publishes, and its connections light as values pass.

Fixes the message pulse while here: interpolating the stroke against the
edge's `color-mix()` resting colour went through oklab and left the gamut,
which turned every pulse on both canvases fluorescent yellow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017MeiWk3Yq12n2pTvnQWYvt
This commit is contained in:
2026-08-16 22:59:31 +02:00
co-authored by Claude Fable 5
parent af3ba51571
commit 4bcd38354b
25 changed files with 790 additions and 4 deletions
+9
View File
@@ -29,6 +29,7 @@ from app.flow.messages import qualify
from app.flow.pipeline import ValidationIssue
from app.flow.schemas import (
NAME_PATTERN,
BrainGraph,
FlowDef,
FlowsPublic,
FlowStatePublic,
@@ -264,6 +265,14 @@ def read_node_types() -> Any:
return node_type_info()
# Above "/{name}" for the same reason "library" is: a flow called "graph" would
# otherwise be unreachable.
@router.get("/graph", response_model=BrainGraph)
def read_graph(controller: FlowControllerDep) -> Any:
"""Every flow as one graph, with nodes talking to the same thing merged."""
return controller.brain_graph()
# -----------------------------------------------------------------------------
# Shared nodes
#
+70
View File
@@ -42,6 +42,9 @@ from app.flow.nodes import (
)
from app.flow.pipeline import Pipeline, ValidationIssue, ValueSource
from app.flow.schemas import (
BrainEdge,
BrainGraph,
BrainNode,
FlowDef,
Health,
NodeDef,
@@ -685,6 +688,73 @@ class FlowController:
found.append((other.name, node.id, provides, requires))
return found
def brain_graph(self) -> BrainGraph:
"""Every published flow as one graph, merged on what its nodes talk to.
Two flows on the same broker topic are one neuron here: the topic is a
single thing, and the path that runs through it is real wiring that no
one flow's canvas can show. Read from the store rather than from the
compiled pipeline, so a node that failed to load still appears — a
broken neuron is exactly what someone comes to this view to find.
"""
groups: dict[str, BrainNode] = {}
# Which group each `flow.node_id` ended up in.
gid_of: dict[str, str] = {}
producers: dict[str, list[str]] = {}
# (member, message) for every input that actually wakes a node.
consumers: list[tuple[str, str]] = []
for flow in self.store.read_all():
for node_def in flow.nodes:
member = f"{flow.name}.{node_def.id}"
node_type = NODE_TYPES.get(node_def.type)
key = (
node_type.cls.instance_key(node_def.params)
if node_type is not None
else None
)
gid = f"{node_def.type}:{key}" if key else member
gid_of[member] = gid
group = groups.get(gid)
if group is None:
# The first member names the neuron. What it points at is
# in the id, which can be a whole topic map and is far too
# long to draw on a circle.
group = groups[gid] = BrainNode(
id=gid,
label=node_def.title or node_def.id,
kind=node_def.type,
)
group.members.append(member)
if flow.name not in group.flows:
group.flows.append(flow.name)
for spec in _bound(node_def.provides):
producers.setdefault(qualify(flow.name, spec.name), []).append(
member
)
for spec in _bound(node_def.requires):
if spec.trigger:
consumers.append((member, qualify(flow.name, spec.name)))
messages: dict[tuple[str, str], set[str]] = {}
for member, message in consumers:
target = gid_of[member]
for producer in producers.get(message, ()):
source = gid_of[producer]
# Everything a merged neuron says to itself is inside it.
if source != target:
messages.setdefault((source, target), set()).add(message)
return BrainGraph(
nodes=sorted(groups.values(), key=lambda node: node.id),
edges=[
BrainEdge(source=source, target=target, messages=sorted(names))
for (source, target), names in sorted(messages.items())
],
)
def publish_message(
self, name: str, value: Any, source: ValueSource | None = None
) -> None:
+15
View File
@@ -166,6 +166,21 @@ class Node:
# to the device, which at-least-once delivery must not cause.
idempotent: bool = True
@classmethod
def instance_key(cls, params: dict[str, Any]) -> str | None:
"""Which outside thing these parameters point at, if any.
Two nodes with the same key talk to the same broker topic, URL or
bucket, however many flows they sit in — which is what the brain graph
draws as one neuron. ``None``, the default, means a node of this type
is only ever itself.
Called with the *stored* parameters, so a credential is still an
unresolved ``{"$secret": ...}`` reference. No key may be built from one:
an id is not a place for a password.
"""
return None
async def start(self, app: FastAPI | None = None) -> None:
"""Begin whatever this node listens to. Called when its flow starts."""
+8
View File
@@ -150,6 +150,14 @@ class HttpNode(Node):
json_schema_extra={"x-secret": True},
)
@classmethod
def instance_key(cls, params: dict[str, Any]) -> str | None:
# ponytail: a webhook's stored url is the path before the flow name is
# prefixed onto it, so two flows both receiving on "/tick" merge into
# one neuron. Key on mode as well if that ever misleads.
url = params.get("url")
return str(url) if url else None
def __init__(
self,
url: str | None = None,
+6
View File
@@ -157,6 +157,12 @@ class InfluxDbNode(Node):
writes: dict[str, dict[str, Any]] = {}
queries: dict[str, dict[str, Any]] = {}
@classmethod
def instance_key(cls, params: dict[str, Any]) -> str | None:
"""The bucket, which is the thing several flows share."""
url, bucket = params.get("url"), params.get("bucket")
return f"{url}/{bucket}" if url and bucket else None
def __init__(
self,
requires: MessageSpec | Iterable[MessageSpec] = (),
+19
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import json
import logging
from collections.abc import Iterable
from enum import Enum
@@ -149,6 +150,24 @@ class MqttNode(Node):
retain: bool = False
keepalive: int = 60
@classmethod
def instance_key(cls, params: dict[str, Any]) -> str | None:
"""The broker and topic, which is one physical thing.
A publisher and a subscriber on the same topic get the same key on
purpose: they are two ends of one wire, and drawing them as one neuron
is the only way the path through the broker shows up at all.
"""
# ponytail: publisher and subscriber merge into one neuron; key on mode
# as well if the two directions ever need telling apart.
fields = cls.Params.model_fields
topic = params.get("topic", fields["topic"].default)
if isinstance(topic, dict):
topic = json.dumps(topic, sort_keys=True)
host = params.get("broker_host", fields["broker_host"].default)
port = params.get("broker_port", fields["broker_port"].default)
return f"{host}:{port}/{topic}"
def __init__(
self,
requires: MessageSpec | Iterable[MessageSpec] = (),
+31
View File
@@ -184,6 +184,37 @@ class ApplyResult(BaseModel):
output: str = ""
class BrainNode(BaseModel):
"""One neuron: a thing the engine talks to, or a node that only computes.
Nodes of the same type pointing at the same outside thing — one broker
topic, one URL, one bucket — are a single entry here, whichever flows they
sit in. ``members`` are the ``flow.node_id`` names behind it, which is also
what the live events are keyed by.
"""
id: str
label: str
kind: str
members: list[str] = Field(default_factory=list)
flows: list[str] = Field(default_factory=list)
class BrainEdge(BaseModel):
"""Messages carrying values from one neuron to another."""
source: str
target: str
messages: list[str] = Field(default_factory=list)
class BrainGraph(BaseModel):
"""Every published flow at once, merged on what its nodes talk to."""
nodes: list[BrainNode] = Field(default_factory=list)
edges: list[BrainEdge] = Field(default_factory=list)
class NodeTypeInfo(BaseModel):
"""A node type the editor can offer, with its parameter schema."""
+10
View File
@@ -136,6 +136,16 @@ async def list_shared_nodes() -> Any:
return await _call("GET", "/flows/library")
@mcp.tool()
async def get_graph() -> Any:
"""Every flow as one graph, with nodes talking to the same thing merged.
Nodes on the same broker topic, URL or bucket come back as a single entry,
which is how wiring that runs between flows shows up.
"""
return await _call("GET", "/flows/graph")
# -----------------------------------------------------------------------------
# Building
# -----------------------------------------------------------------------------