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:
co-authored by
Claude Fable 5
parent
7344eac262
commit
3724b68f23
@@ -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")
|
||||
Reference in New Issue
Block a user