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,75 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.flow.messages import MessageSpec
|
||||
from app.flow.schemas import FlowDef, NodeDef
|
||||
from app.flow.store import FlowNotFound, FlowStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path: Path) -> FlowStore:
|
||||
return FlowStore(tmp_path / "flows")
|
||||
|
||||
|
||||
def commit_count(store: FlowStore) -> int:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(store.root), "rev-list", "--count", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return int(result.stdout.strip())
|
||||
|
||||
|
||||
def a_flow() -> FlowDef:
|
||||
return FlowDef(
|
||||
name="heating",
|
||||
nodes=[NodeDef(id="sensor", provides=[MessageSpec(name="temp")])],
|
||||
)
|
||||
|
||||
|
||||
def test_flow_round_trips(store: FlowStore):
|
||||
store.write_flow(a_flow())
|
||||
|
||||
assert store.list_flows() == ["heating"]
|
||||
assert store.read_flow("heating").nodes[0].provides[0].name == "temp"
|
||||
|
||||
|
||||
def test_every_change_is_committed(store: FlowStore):
|
||||
before = commit_count(store)
|
||||
|
||||
store.write_flow(a_flow())
|
||||
assert commit_count(store) == before + 1
|
||||
|
||||
store.write_node_source(
|
||||
"heating", "sensor", "def process(params):\n return {}\n"
|
||||
)
|
||||
assert commit_count(store) == before + 2
|
||||
|
||||
|
||||
def test_saving_unchanged_content_does_nothing(store: FlowStore):
|
||||
store.write_flow(a_flow())
|
||||
commits = commit_count(store)
|
||||
|
||||
# Autosave repeats the same document; history should not grow.
|
||||
assert store.write_flow(a_flow()) is False
|
||||
assert commit_count(store) == commits
|
||||
|
||||
|
||||
def test_missing_flow_is_reported(store: FlowStore):
|
||||
with pytest.raises(FlowNotFound):
|
||||
store.read_flow("nope")
|
||||
|
||||
|
||||
def test_deleting_removes_flow_and_its_nodes(store: FlowStore):
|
||||
store.write_flow(a_flow())
|
||||
store.write_node_source(
|
||||
"heating", "sensor", "def process(params):\n return {}\n"
|
||||
)
|
||||
|
||||
store.delete_flow("heating")
|
||||
|
||||
assert store.list_flows() == []
|
||||
assert not (store.root / "heating").exists()
|
||||
Reference in New Issue
Block a user