Flows can now be taken off the engine and put back. Stopped state lives in a runtime.json beside the flow, not in the flow document: the canvas autosaves that document, so a stopped flow would otherwise start itself again on the next edit. A stopped flow gets no subscriptions, schedules or webhooks, its nodes are skipped by the scheduler, and running it answers 409. Pausing holds a flow's nodes while its values keep arriving, so the canvas still shows what is coming in. Node code is user code and print is how it says things, so stdout is teed through a contextvar sink active only during a node execution — one event per execution, capped, so a chatty node cannot outrun the stream. A node that fails sends its traceback the same way, trimmed to the author's own frames. The dock gains a logs panel and a pause control; the dashboard replaces its placeholder with what is running, stopped or failing; the edge inspector can send the last message again. Single-stepping is deferred and noted: the scheduler keeps no progress between calls, so a step button would re-run the same node rather than advance. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
104 lines
2.9 KiB
Python
104 lines
2.9 KiB
Python
"""Stopping a flow takes it off the engine; pausing holds its nodes."""
|
|
|
|
from pathlib import Path
|
|
|
|
from app.flow.messages import DType, MessageSpec
|
|
from app.flow.nodes import Node
|
|
from app.flow.pipeline import Pipeline
|
|
from app.flow.store import FlowStore
|
|
|
|
|
|
def spec(name: str) -> MessageSpec:
|
|
return MessageSpec(name=name, dtype=DType.FLOAT)
|
|
|
|
|
|
def make_node(node_id: str, flow: str, f, requires=(), provides=()) -> Node:
|
|
node = Node(f=f, requires=list(requires), provides=list(provides), name=node_id)
|
|
node.assign_flow(flow, node_id)
|
|
return node
|
|
|
|
|
|
def a_chain(flow: str, ran: list[str]) -> list[Node]:
|
|
"""source → middle: two nodes, so stepping has somewhere to stop."""
|
|
|
|
def source(params):
|
|
ran.append(f"{flow}.source")
|
|
return {"temp": 20.0}
|
|
|
|
def middle(temp, params):
|
|
ran.append(f"{flow}.middle")
|
|
return {"warm": temp > 10}
|
|
|
|
return [
|
|
make_node("source", flow, source, provides=[spec("temp")]),
|
|
make_node(
|
|
"middle",
|
|
flow,
|
|
middle,
|
|
requires=[spec("temp")],
|
|
provides=[MessageSpec(name="warm", dtype=DType.BOOL)],
|
|
),
|
|
]
|
|
|
|
|
|
def test_a_stopped_flow_runs_nothing_and_its_neighbours_carry_on():
|
|
ran: list[str] = []
|
|
pipeline = Pipeline(
|
|
nodes=a_chain("stopped", ran) + a_chain("running", ran),
|
|
disabled_flows={"stopped"},
|
|
)
|
|
|
|
pipeline.run({})
|
|
|
|
assert not any(name.startswith("stopped.") for name in ran)
|
|
assert {"running.source", "running.middle"} <= set(ran)
|
|
|
|
|
|
def test_a_stopped_flow_ignores_a_trigger_from_its_own_nodes():
|
|
ran: list[str] = []
|
|
nodes = a_chain("stopped", ran)
|
|
pipeline = Pipeline(nodes=nodes, disabled_flows={"stopped"})
|
|
|
|
# What an MQTT subscription or a webhook would do.
|
|
nodes[0].inject({"temp": 20.0})
|
|
|
|
assert ran == []
|
|
assert "stopped.temp" not in pipeline.state
|
|
|
|
|
|
def test_a_paused_flow_still_takes_values_but_acts_on_none_of_them():
|
|
ran: list[str] = []
|
|
nodes = a_chain("demo", ran)
|
|
pipeline = Pipeline(nodes=nodes)
|
|
|
|
pipeline.pause("demo")
|
|
nodes[0].inject({"temp": 20.0})
|
|
|
|
# The value is there to look at; nothing downstream of it ran.
|
|
assert pipeline.state["demo.temp"] == 20.0
|
|
assert ran == []
|
|
|
|
|
|
def test_resuming_runs_what_was_held_back():
|
|
ran: list[str] = []
|
|
pipeline = Pipeline(nodes=a_chain("demo", ran))
|
|
|
|
pipeline.pause("demo")
|
|
pipeline.run({})
|
|
assert ran == []
|
|
|
|
pipeline.resume("demo")
|
|
assert ran == ["demo.source", "demo.middle"]
|
|
assert pipeline.paused_flows() == []
|
|
|
|
|
|
def test_stopped_survives_a_restart(tmp_path: Path):
|
|
store = FlowStore(tmp_path / "flows")
|
|
assert store.read_enabled("heating") is True
|
|
|
|
store.write_enabled("heating", False)
|
|
assert store.read_enabled("heating") is False
|
|
|
|
# A second store over the same directory is what a restart looks like.
|
|
assert FlowStore(tmp_path / "flows").read_enabled("heating") is False
|