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
# -----------------------------------------------------------------------------
+82
View File
@@ -0,0 +1,82 @@
"""Nodes talking to the same outside thing are one neuron, across flows.
That merge is the whole point of the brain graph: a broker topic is a single
physical thing, and two flows sharing one are wired together through it even
though neither canvas can draw the other end.
"""
from pathlib import Path
import pytest
from app.flow.controller import FlowController
from app.flow.messages import DType, MessageSpec
from app.flow.nodes import MqttNode
from app.flow.schemas import FlowDef, NodeDef
from app.flow.store import FlowStore
BROKER = {"broker_host": "mosquitto", "broker_port": 1883, "topic": "sensors/temp"}
def subscriber(node_id: str) -> NodeDef:
return NodeDef(
id=node_id,
type="mqtt",
params=dict(BROKER),
provides=[MessageSpec(name="temp", dtype=DType.FLOAT)],
)
@pytest.fixture
def controller(tmp_path: Path) -> FlowController:
store = FlowStore(tmp_path / "flows")
store.write_flow(
FlowDef(
name="house",
nodes=[
subscriber("sensor"),
NodeDef(
id="scale",
type="change",
requires=[MessageSpec(name="temp", dtype=DType.FLOAT)],
provides=[MessageSpec(name="scaled", dtype=DType.FLOAT)],
),
],
)
)
store.write_flow(FlowDef(name="shed", nodes=[subscriber("sensor")]))
return FlowController(store)
def test_two_flows_on_one_topic_are_one_neuron(controller: FlowController):
graph = controller.brain_graph()
merged = [node for node in graph.nodes if node.kind == "mqtt"]
assert len(merged) == 1
assert merged[0].id == "mqtt:mosquitto:1883/sensors/temp"
assert merged[0].members == ["house.sensor", "shed.sensor"]
assert merged[0].flows == ["house", "shed"]
def test_a_node_with_no_outside_thing_stays_its_own(controller: FlowController):
graph = controller.brain_graph()
scale = next(node for node in graph.nodes if node.kind == "change")
assert scale.id == "house.scale"
assert scale.members == ["house.scale"]
def test_an_edge_carries_the_qualified_message(controller: FlowController):
graph = controller.brain_graph()
assert [(edge.source, edge.target, edge.messages) for edge in graph.edges] == [
("mqtt:mosquitto:1883/sensors/temp", "house.scale", ["house.temp"])
]
def test_a_credential_never_reaches_the_key():
# Stored params, so a secret is still a reference. Neither its name nor its
# value belongs in something the browser gets to see.
key = MqttNode.instance_key({**BROKER, "password": {"$secret": "broker_pw"}})
assert key == "mosquitto:1883/sensors/temp"