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:
@@ -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
|
||||
#
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] = (),
|
||||
|
||||
@@ -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] = (),
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user