Add the flow API: typed messages, git-backed store, REST and live events
Makes the flow engine reachable from the API, which is what M3 needs before
any of it can reach the browser.
- app/flow is a package now; the prototype's watch-dir scripts and the
matplotlib/networkx visualiser are gone with their dependencies.
- Messages carry a serializable dtype instead of a live Python type, and a
port name, so the graph can speak qualified names while node functions keep
local arguments. Redis state is JSON, not pickle.
- Message names are namespaced per flow ("heating.temp"); a bare name resolves
to its own flow, a dotted one crosses flows.
- Several nodes may provide the same message: producers are a list, so fan-in
is a real edge instead of a silently dropped one.
- Flows are stored as flow.json plus node sources in a git repository, one
commit per save, with identical saves skipped so autosave stays quiet.
- Node failures are isolated and reported per node; validate() returns cycles
and unconnected inputs instead of raising deep in a run.
- Credentials live in an encrypted store and are referenced as {"$secret": …}.
- Engine events reach websocket clients through a bus, so values, node status
and execution show up live.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WzrvW7rjQbynnhF6pxh6i
This commit is contained in:
co-authored by
Claude Fable 5
parent
61be29827d
commit
06a4506767
@@ -0,0 +1,146 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
PREFIX = f"{settings.API_V1_STR}/flows"
|
||||
|
||||
WORKING_NODE = """
|
||||
def process(params):
|
||||
return {"reading": 21.5}
|
||||
"""
|
||||
|
||||
BROKEN_NODE = """
|
||||
def process(params):
|
||||
raise RuntimeError("boom")
|
||||
"""
|
||||
|
||||
|
||||
def a_flow(name: str = "demo") -> dict:
|
||||
return {
|
||||
"name": name,
|
||||
"title": "Demo",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "sensor",
|
||||
"type": "python",
|
||||
"position": {"x": 0, "y": 0},
|
||||
"provides": [{"name": "reading", "dtype": "float"}],
|
||||
},
|
||||
{
|
||||
"id": "logger",
|
||||
"type": "python",
|
||||
"position": {"x": 240, "y": 0},
|
||||
"requires": [{"name": "reading", "dtype": "float"}],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_flows_require_authentication(client: TestClient) -> None:
|
||||
assert client.get(f"{PREFIX}/").status_code == 401
|
||||
|
||||
|
||||
def test_save_then_read_flow(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
response = client.put(
|
||||
f"{PREFIX}/demo", headers=superuser_token_headers, json=a_flow()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.get(f"{PREFIX}/demo", headers=superuser_token_headers)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["definition"]["title"] == "Demo"
|
||||
assert {node["id"] for node in body["definition"]["nodes"]} == {"sensor", "logger"}
|
||||
|
||||
|
||||
def test_name_mismatch_is_rejected(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
response = client.put(
|
||||
f"{PREFIX}/other", headers=superuser_token_headers, json=a_flow("demo")
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_broken_node_is_reported_and_siblings_stay_active(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
client.put(f"{PREFIX}/demo", headers=superuser_token_headers, json=a_flow())
|
||||
client.put(
|
||||
f"{PREFIX}/demo/nodes/sensor/source",
|
||||
headers=superuser_token_headers,
|
||||
json={"code": WORKING_NODE},
|
||||
)
|
||||
|
||||
response = client.put(
|
||||
f"{PREFIX}/demo/nodes/logger/source",
|
||||
headers=superuser_token_headers,
|
||||
json={"code": "def process(reading, params:\n"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "error"
|
||||
|
||||
statuses = client.get(f"{PREFIX}/demo", headers=superuser_token_headers).json()
|
||||
by_id = {node["id"]: node for node in statuses["nodes"]}
|
||||
assert by_id["demo.sensor"]["status"] == "active"
|
||||
assert by_id["demo.logger"]["status"] == "error"
|
||||
|
||||
|
||||
def test_running_a_flow_produces_values(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
client.put(f"{PREFIX}/demo", headers=superuser_token_headers, json=a_flow())
|
||||
client.put(
|
||||
f"{PREFIX}/demo/nodes/sensor/source",
|
||||
headers=superuser_token_headers,
|
||||
json={"code": WORKING_NODE},
|
||||
)
|
||||
client.put(
|
||||
f"{PREFIX}/demo/nodes/logger/source",
|
||||
headers=superuser_token_headers,
|
||||
json={"code": "def process(reading, params):\n return {}\n"},
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"{PREFIX}/demo/run", headers=superuser_token_headers, json={"inputs": {}}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["values"]["demo.reading"]["value"] == 21.5
|
||||
|
||||
|
||||
def test_unconnected_input_is_surfaced(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
flow = a_flow()
|
||||
flow["nodes"][0]["provides"] = [] # nothing produces "reading" any more
|
||||
client.put(f"{PREFIX}/demo", headers=superuser_token_headers, json=flow)
|
||||
|
||||
issues = client.post(
|
||||
f"{PREFIX}/demo/validate", headers=superuser_token_headers
|
||||
).json()["issues"]
|
||||
assert any(issue["code"] == "unconnected_input" for issue in issues)
|
||||
|
||||
|
||||
def test_delete_flow(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
client.put(f"{PREFIX}/demo", headers=superuser_token_headers, json=a_flow())
|
||||
|
||||
assert (
|
||||
client.delete(f"{PREFIX}/demo", headers=superuser_token_headers).status_code
|
||||
== 200
|
||||
)
|
||||
assert (
|
||||
client.get(f"{PREFIX}/demo", headers=superuser_token_headers).status_code == 404
|
||||
)
|
||||
|
||||
|
||||
def test_node_types_are_listed(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
types = client.get(f"{PREFIX}/node-types", headers=superuser_token_headers).json()
|
||||
by_type = {entry["type"]: entry for entry in types}
|
||||
assert by_type["python"]["has_source"] is True
|
||||
assert "properties" in by_type["mqtt"]["params_schema"]
|
||||
Reference in New Issue
Block a user