Add the connector contract, reusable nodes and per-port intervals

Connectors are the device-facing node class third parties write, so the
surface they build against is versioned and documented: ConnectorNode carries
a declared contract version, a polling loop that publishes only what changed
and reports health around it, and parameters whose credential fields are
marked x-secret so the editor offers the secrets store instead of a text box.
They are found through the fluksio.node_types entry point group, with the
package's own metadata as the manifest. docs/connectors/ has the contract and
the authoring guide; connector-skeleton/ is a working one to copy.

The controller no longer knows what any node type is: start, stop and
report_health are protocol methods on Node, and the built-ins were migrated to
them first, so the hooks a connector implements are the ones the engine has
been driving all along.

Marking a node reusable moves its source to _lib/ and points the node at it by
name. Other flows instantiate it with their own ports and settings, one fix
reaches all of them, and a shared source still in use cannot be deleted.

Ports gained an interval: an output publishes, and an input wakes its node, at
most every n seconds. State keeps the latest value, so only the delivery is
skipped, and pressing Run is never throttled.

Also fixes autosave sending no version on its first save of a session, which
made every flow saved more than once conflict with itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Melvin Strobl
2026-08-15 23:57:44 +02:00
co-authored by Claude Fable 5
parent 7344eac262
commit 3724b68f23
22 changed files with 1541 additions and 62 deletions
+156
View File
@@ -0,0 +1,156 @@
"""The connector contract: polling, deduplication, health and discovery."""
import asyncio
from typing import Any
from app.flow.connector import CONTRACT_VERSION, ConnectorNode
from app.flow.controller import NODE_TYPES
from app.flow.messages import DType, MessageSpec
from app.flow.nodes import Node
from app.flow.pipeline import Pipeline
from app.flow.plugins import load_plugins
class Sensor(ConnectorNode):
contract = CONTRACT_VERSION
title = "Test sensor"
description = "Reads whatever it is told to."
class Params(ConnectorNode.Params):
secret_token: str | None = None
def __init__(self, readings: list[Any] | None = None, **kwargs: Any) -> None:
super().__init__(**kwargs)
self._readings = list(readings or [])
self.polls = 0
async def poll(self) -> dict[str, Any] | None:
self.polls += 1
if not self._readings:
return None
value = self._readings.pop(0)
if isinstance(value, Exception):
raise value
return {"reading": value}
def a_sensor(readings: list[Any], **params: Any) -> Sensor:
node = Sensor(
readings=readings,
provides=[MessageSpec(name="reading", dtype=DType.FLOAT)],
params={"poll_interval": 0.01, **params},
)
node.assign_flow("demo", "sensor")
return node
def run_briefly(node: ConnectorNode, seconds: float = 0.12) -> None:
"""Start the poll loop, let it tick a few times, stop it."""
async def cycle() -> None:
await node.start()
await asyncio.sleep(seconds)
await node.stop()
asyncio.run(cycle())
def test_polling_publishes_what_it_reads():
node = a_sensor([21.5])
pipeline = Pipeline(nodes=[node])
run_briefly(node)
assert pipeline.state["demo.reading"] == 21.5
def test_an_unchanged_reading_is_not_republished():
node = a_sensor([21.5, 21.5, 21.5])
consumer_ran: list[float] = []
def consume(reading, params):
consumer_ran.append(reading)
return None
consumer = Node(
f=consume,
requires=[MessageSpec(name="reading", dtype=DType.FLOAT)],
name="consumer",
)
consumer.assign_flow("demo", "consumer")
Pipeline(nodes=[node, consumer])
run_briefly(node)
# Polled repeatedly, but the value never changed, so downstream ran once.
assert node.polls > 1
assert consumer_ran == [21.5]
def test_a_failing_poll_reports_down_and_keeps_going():
health: list[tuple[str, str | None]] = []
node = a_sensor([RuntimeError("device unplugged"), 21.5])
node._on_health = lambda _node, status, detail: health.append((status, detail))
Pipeline(nodes=[node])
run_briefly(node)
assert ("down", "RuntimeError: device unplugged") in health
# It recovered rather than giving up.
assert health[-1][0] == "ok"
def test_a_credential_param_is_marked_for_the_editor():
schema = Sensor.Params.model_json_schema()
assert schema["properties"]["poll_interval"]["default"] == 0
assert "secret_token" in schema["properties"]
def test_a_connector_is_discovered_from_its_entry_point(monkeypatch):
class FakeDist:
name = "fluksio-connector-test"
version = "0.1.0"
class FakeEntry:
name = "test_sensor"
dist = FakeDist()
def load(self):
return Sensor
monkeypatch.setattr("app.flow.plugins.entry_points", lambda group: [FakeEntry()])
try:
assert load_plugins() == ["test_sensor"]
assert NODE_TYPES["test_sensor"].plugin == "fluksio-connector-test 0.1.0"
assert NODE_TYPES["test_sensor"].title == "Test sensor"
finally:
NODE_TYPES.pop("test_sensor", None)
def test_a_connector_written_for_another_contract_is_refused(monkeypatch):
class Outdated(ConnectorNode):
contract = CONTRACT_VERSION + 1
class FakeEntry:
name = "outdated"
dist = None
def load(self):
return Outdated
monkeypatch.setattr("app.flow.plugins.entry_points", lambda group: [FakeEntry()])
assert load_plugins() == []
assert "outdated" not in NODE_TYPES
def test_a_connector_may_not_take_over_a_built_in_type(monkeypatch):
class FakeEntry:
name = "mqtt"
dist = None
def load(self): # pragma: no cover - never reached
raise AssertionError("should not be loaded")
monkeypatch.setattr("app.flow.plugins.entry_points", lambda group: [FakeEntry()])
assert load_plugins() == []
assert NODE_TYPES["mqtt"].plugin is None
+100
View File
@@ -0,0 +1,100 @@
"""Per-port intervals: deliver at most every x seconds."""
from app.flow.messages import DType, MessageSpec
from app.flow.nodes import Node
from app.flow.pipeline import Pipeline
def spec(name: str, interval: float = 0) -> MessageSpec:
return MessageSpec(name=name, dtype=DType.FLOAT, interval=interval)
def make_node(node_id: str, f, requires=(), provides=()) -> Node:
node = Node(f=f, requires=list(requires), provides=list(provides), name=node_id)
node.assign_flow("demo", node_id)
return node
def test_a_limited_output_publishes_once_inside_its_window():
readings = iter([1.0, 2.0, 3.0])
source = make_node(
"source",
lambda params: {"temp": next(readings)},
provides=[spec("temp", interval=60)],
)
pipeline = Pipeline(nodes=[source])
pipeline.run({})
assert pipeline.state["demo.temp"] == 1.0
# Same window: the reading is taken but not published.
pipeline.run({})
assert pipeline.state["demo.temp"] == 1.0
def test_an_unlimited_output_publishes_every_time():
readings = iter([1.0, 2.0])
source = make_node(
"source",
lambda params: {"temp": next(readings)},
provides=[spec("temp")],
)
pipeline = Pipeline(nodes=[source])
pipeline.run({})
pipeline.run({})
assert pipeline.state["demo.temp"] == 2.0
def test_a_limited_input_wakes_its_node_once_inside_the_window():
seen: list[float] = []
source = make_node("source", lambda params: {"temp": 20.0}, provides=[spec("temp")])
consumer = make_node(
"consumer",
lambda temp, params: seen.append(temp),
requires=[spec("temp", interval=60)],
)
# Binding the nodes is what the pipeline is for here.
Pipeline(nodes=[source, consumer])
source.inject({"temp": 20.0})
source.inject({"temp": 21.0})
assert seen == [20.0]
def test_an_unthrottled_input_still_wakes_a_node_beside_a_throttled_one():
seen: list[tuple[float, float]] = []
fast = make_node("fast", lambda params: None, provides=[spec("quick")])
slow = make_node("slow", lambda params: None, provides=[spec("rare")])
consumer = make_node(
"consumer",
lambda quick, rare, params: seen.append((quick, rare)),
requires=[spec("quick"), spec("rare", interval=60)],
)
pipeline = Pipeline(nodes=[fast, slow, consumer])
pipeline.state["demo.rare"] = 1.0
fast.inject({"quick": 1.0})
fast.inject({"quick": 2.0})
# The throttled port holds back only itself.
assert [quick for quick, _ in seen] == [1.0, 2.0]
def test_a_manual_run_is_never_throttled_on_its_inputs():
seen: list[float] = []
source = make_node("source", lambda params: {"temp": 20.0}, provides=[spec("temp")])
consumer = make_node(
"consumer",
lambda temp, params: seen.append(temp),
requires=[spec("temp", interval=3600)],
)
pipeline = Pipeline(nodes=[source, consumer])
# Pressing Run is an explicit ask; the interval governs the flow's own
# traffic, not what the person in front of it asked for.
pipeline.run({})
pipeline.run({})
assert len(seen) == 2
+96
View File
@@ -0,0 +1,96 @@
"""Nodes shared across flows: one source, many instances."""
from pathlib import Path
import pytest
from app.flow.messages import MessageSpec
from app.flow.schemas import FlowDef, NodeDef
from app.flow.store import FlowStore, LibExists, LibNotFound
SOURCE = "def process(params):\n return {'temp': 1}\n"
@pytest.fixture
def store(tmp_path: Path) -> FlowStore:
return FlowStore(tmp_path / "flows")
def a_flow(name: str = "heating") -> FlowDef:
return FlowDef(
name=name,
nodes=[NodeDef(id="sensor", provides=[MessageSpec(name="temp")])],
)
def test_sharing_moves_the_source_and_points_the_node_at_it(store: FlowStore):
store.write_flow(a_flow())
store.write_node_source("heating", "sensor", SOURCE)
store.share_node("heating", "sensor", "read_temp")
assert store.list_lib() == ["read_temp"]
assert store.read_lib_source("read_temp") == SOURCE
node = store.read_flow("heating", draft=True).nodes[0]
assert node.source_ref == "read_temp"
# The private copy is gone; the library one is what it runs.
assert not (store.root / "heating" / "nodes" / "sensor.py").exists()
def test_the_library_is_not_mistaken_for_a_flow(store: FlowStore):
store.write_flow(a_flow())
store.write_node_source("heating", "sensor", SOURCE)
store.share_node("heating", "sensor", "read_temp")
assert store.list_flows() == ["heating"]
assert [flow.name for flow in store.read_all()] == ["heating"]
def test_a_second_flow_can_use_the_same_source(store: FlowStore):
store.write_flow(a_flow())
store.write_node_source("heating", "sensor", SOURCE)
store.share_node("heating", "sensor", "read_temp")
store.write_flow(
FlowDef(
name="cooling",
nodes=[
NodeDef(
id="sensor",
source_ref="read_temp",
provides=[MessageSpec(name="temp")],
)
],
)
)
assert store.usages("read_temp") == ["cooling.sensor", "heating.sensor"]
def test_a_shared_name_is_not_taken_twice(store: FlowStore):
store.write_flow(a_flow())
store.write_node_source("heating", "sensor", SOURCE)
store.share_node("heating", "sensor", "read_temp")
store.write_flow(FlowDef(name="cooling", nodes=[NodeDef(id="sensor")]))
with pytest.raises(LibExists):
store.share_node("cooling", "sensor", "read_temp")
def test_unsharing_takes_a_private_copy_back(store: FlowStore):
store.write_flow(a_flow())
store.write_node_source("heating", "sensor", SOURCE)
store.share_node("heating", "sensor", "read_temp")
store.unshare_node("heating", "sensor")
node = store.read_flow("heating", draft=True).nodes[0]
assert node.source_ref is None
assert store.read_node_source("heating", "sensor", draft=True) == SOURCE
# The library keeps its copy for whoever else is using it.
assert store.list_lib() == ["read_temp"]
def test_a_missing_shared_source_is_reported(store: FlowStore):
with pytest.raises(LibNotFound):
store.read_lib_source("nothing_here")