Files
app/backend/tests/flow/test_provenance.py
T
stroblmeandClaude Fable 5 75c26ef000 Say what caused a value, and draw what is not a node
Moving a dashboard slider lit up an edge between two nodes that had done
nothing. The canvas pulsed on the message's timestamp alone, and a message
has no idea who published it — so it credited whichever node happened to
be drawn as a producer.

That was never only about dashboards. Two nodes producing one message
pulsed both their edges whichever fired, and a message produced in another
flow changed with nothing on screen to account for it at all.

Values now carry their cause: a node, a dashboard widget, another flow, an
agent or an API caller. An edge pulses only for the producer that actually
published, and the edge inspector says where a value came from when it did
not come from a node.

What is not a node in this flow is now drawn as one — a label rather than
a card, because a dashboard with twenty tiles would otherwise bury the
logic the canvas exists to show. That covers cross-flow wiring too, which
is the link in/out affordance that has been missing.

They are never part of the document. They join at render, after everything
that reads or writes the canvas nodes, so an autosave, an undo or a delete
cannot reach them — with a Playwright test that drags a node and asserts
the stored flow still holds exactly what it did.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
2026-08-16 15:51:54 +02:00

155 lines
4.9 KiB
Python

"""Who caused a value.
The canvas draws an edge per producer of a message. Without knowing which one
actually published, it pulses all of them — and when the cause is a dashboard
control or another flow, it pulses a node that did nothing at all.
"""
from app.flow.dashboards import (
DashboardDef,
DashboardStore,
PageDef,
SectionDef,
WidgetDef,
)
from app.flow.events import EventBus
from app.flow.messages import DType, MessageSpec
from app.flow.nodes import Node
from app.flow.pipeline import Pipeline, ValueSource
from app.flow.state import MemoryState
from app.flow.store import FlowStore
def collect(bus: EventBus) -> list[dict]:
events: list[dict] = []
bus.publish = events.append # type: ignore[method-assign]
return events
def temp_node() -> Node:
node = Node(
f=lambda params: {"temp": 21.0},
provides=[MessageSpec(name="temp", port="temp", dtype=DType.FLOAT)],
name="sensor",
)
node.assign_flow("house", "sensor")
return node
def test_a_value_a_node_produced_names_that_node():
bus = EventBus()
events = collect(bus)
node = temp_node()
pipeline = Pipeline(nodes=[node], state=MemoryState(), events=bus)
pipeline.run()
published = [e for e in events if e["type"] == "message_value"]
assert published[0]["source"] == {
"kind": "node",
"id": "house.sensor",
"label": "sensor",
"detail": "",
}
def test_a_value_a_node_injected_names_that_node():
"""An MQTT message or a webhook arrives this way rather than by executing."""
bus = EventBus()
events = collect(bus)
node = temp_node()
pipeline = Pipeline(nodes=[node], state=MemoryState(), events=bus)
pipeline.apply_outputs(node, {"house.temp": 19.0})
published = [e for e in events if e["type"] == "message_value"]
assert published[0]["source"]["id"] == "house.sensor"
def test_a_value_from_a_dashboard_says_so_rather_than_blaming_a_node():
"""The bug this exists for: a slider must not light up a node's edge."""
bus = EventBus()
events = collect(bus)
pipeline = Pipeline(nodes=[temp_node()], state=MemoryState(), events=bus)
pipeline.publish(
{"house.temp": 25.0},
ValueSource(
kind="dashboard", id="panel", label="Setpoint", detail="slider"
),
)
published = [e for e in events if e["type"] == "message_value"]
assert published[0]["source"]["kind"] == "dashboard"
assert published[0]["source"]["label"] == "Setpoint"
def test_a_value_from_nowhere_in_particular_is_still_attributed():
bus = EventBus()
events = collect(bus)
pipeline = Pipeline(nodes=[temp_node()], state=MemoryState(), events=bus)
pipeline.publish({"house.temp": 25.0})
published = [e for e in events if e["type"] == "message_value"]
assert published[0]["source"]["kind"] == "api"
# ---------------------------------------------------------------------------
# What the canvas draws for it
# ---------------------------------------------------------------------------
def test_the_widgets_wired_into_a_flow_are_reported(tmp_path):
store = DashboardStore(FlowStore(tmp_path / "flows"))
store.write(
DashboardDef(
name="panel",
title="Panel",
pages=[
PageDef(
id="main",
sections=[
SectionDef(
id="main",
widgets=[
WidgetDef(
id="setpoint",
type="slider",
title="Setpoint",
config={"target": "house.setpoint"},
),
WidgetDef(
id="reading",
type="stat",
title="Reading",
config={"message": "house.temp"},
),
# Another flow's message: not this flow's business.
WidgetDef(
id="elsewhere",
type="stat",
config={"message": "garage.temp"},
),
],
)
],
)
],
)
)
bindings = store.bindings_for("house")
assert [b["widget"] for b in bindings] == ["setpoint", "reading"]
setpoint = bindings[0]
assert setpoint["provides"] == "house.setpoint"
assert setpoint["requires"] == []
assert bindings[1]["requires"] == ["house.temp"]
def test_a_flow_nothing_points_at_has_no_endpoints(tmp_path):
store = DashboardStore(FlowStore(tmp_path / "flows"))
assert store.bindings_for("house") == []