Start, stop and pause flows, and show what their nodes print

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>
This commit is contained in:
Melvin Strobl
2026-08-15 23:35:08 +02:00
co-authored by Claude Fable 5
parent 606ab3c423
commit 7344eac262
29 changed files with 1410 additions and 48 deletions
+124
View File
@@ -0,0 +1,124 @@
"""What a node prints reaches the editor, attributed to that node."""
import sys
from typing import Any
from app.flow import logs
from app.flow.messages import DType, MessageSpec
from app.flow.nodes import Node
from app.flow.pipeline import Pipeline
def run_with_capture(nodes: list[Node], bus: "RecordingBus") -> None:
"""Run a graph the way the app does, tee first.
The app installs the tee once at startup; pytest replaces ``sys.stdout``
around each test, so it is installed here rather than in a fixture.
"""
logs.install()
Pipeline(nodes=nodes, events=bus).run({})
class RecordingBus:
"""Stands in for the event bus without an event loop behind it."""
def __init__(self) -> None:
self.events: list[dict[str, Any]] = []
def publish(self, event: dict[str, Any]) -> None:
self.events.append(event)
def make_node(node_id: str, f, provides=()) -> Node:
node = Node(f=f, provides=list(provides), name=node_id)
node.assign_flow("demo", node_id)
return node
def logs_of(bus: RecordingBus) -> list[dict[str, Any]]:
return [event for event in bus.events if event["type"] == "node_log"]
def test_what_a_node_prints_is_reported_against_it():
def talkative(params):
print("value looks fine")
return {"temp": 20.0}
bus = RecordingBus()
node = make_node("chatty", talkative, provides=[MessageSpec(name="temp")])
run_with_capture([node], bus)
captured = logs_of(bus)
assert len(captured) == 1
assert captured[0]["node"] == "demo.chatty"
assert captured[0]["level"] == "info"
assert "value looks fine" in captured[0]["text"]
def test_a_quiet_node_produces_no_log_event():
bus = RecordingBus()
node = make_node(
"quiet",
lambda params: {"temp": 20.0},
provides=[MessageSpec(name="temp", dtype=DType.FLOAT)],
)
run_with_capture([node], bus)
assert logs_of(bus) == []
def test_a_failing_node_reports_its_traceback():
# Compiled the way the controller compiles node source, because the frame
# trimming keys on that filename.
namespace: dict[str, Any] = {}
exec(
compile(
'def process(params):\n print("about to fail")\n'
' raise RuntimeError("boom")\n',
"<node demo.broken>",
"exec",
),
namespace,
)
bus = RecordingBus()
run_with_capture([make_node("broken", namespace["process"])], bus)
captured = logs_of(bus)
assert len(captured) == 1
assert captured[0]["level"] == "error"
# Both what it printed and where it broke, which the node bubble has no
# room for.
assert "about to fail" in captured[0]["text"]
assert "RuntimeError: boom" in captured[0]["text"]
# The frames above the node belong to the engine that called it.
assert "pipeline.py" not in captured[0]["text"]
def test_a_flood_is_truncated_rather_than_streamed():
def noisy(params):
for index in range(1000):
print(f"line {index}")
return None
bus = RecordingBus()
run_with_capture([make_node("noisy", noisy)], bus)
captured = logs_of(bus)
# One event per execution, whatever the node prints.
assert len(captured) == 1
assert captured[0]["truncated"] is True
assert len(captured[0]["text"]) <= 8192
def test_printing_outside_a_node_still_reaches_the_real_stream(capsys):
logs.install()
print("server talking")
assert "server talking" in capsys.readouterr().out
def test_installing_twice_does_not_stack_tees():
logs.install()
once = sys.stdout
logs.install()
assert sys.stdout is once