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>
101 lines
3.1 KiB
Python
101 lines
3.1 KiB
Python
"""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
|