The panel is going to sparkline how a value moved, and nothing kept more
than the latest one. Emissions now append to a capped list beside the
value — 120 points, enough to fill a sparkline without making Redis a
time-series store — and `/flows/{flow}/history/{message}` hands it back
oldest first.
Only numbers are recorded, bools included as neither, so a string
message costs nothing at all. The response carries `numeric` so an empty
series reads as "not plottable" rather than "nothing yet". The append is
one pipelined round-trip per emission batch and sits outside the lock,
since the list is append-only.
MemoryState keeps the same window in a deque, so the endpoint answers
without Redis too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkmeRiyeYmVZqJVwuyHq9o
59 lines
1.8 KiB
Python
59 lines
1.8 KiB
Python
"""Message history: the series a node panel draws as a sparkline."""
|
|
|
|
from app.flow.messages import MessageSpec
|
|
from app.flow.nodes import Node
|
|
from app.flow.pipeline import Pipeline
|
|
from app.flow.state import HISTORY_LIMIT, MemoryState
|
|
|
|
|
|
def emitter(values) -> Node:
|
|
"""A node publishing the given values, one per execution."""
|
|
remaining = list(values)
|
|
|
|
def process(params):
|
|
return {"out": remaining.pop(0)}
|
|
|
|
node = Node(f=process, requires=[], provides=[MessageSpec(name="out")], name="src")
|
|
node.assign_flow("f", "src")
|
|
return node
|
|
|
|
|
|
def test_the_series_follows_the_order_values_were_published():
|
|
node = emitter([1.0, 2.0, 3.0])
|
|
pipeline = Pipeline(nodes=[node])
|
|
|
|
# An injecting node publishes directly, an executed one goes through the
|
|
# executor; both paths land in the same series.
|
|
node.inject()
|
|
pipeline.run()
|
|
pipeline.run()
|
|
|
|
points = pipeline.state.history("f.out")
|
|
assert [value for _, value in points] == [1.0, 2.0, 3.0]
|
|
assert [ts for ts, _ in points] == sorted(ts for ts, _ in points)
|
|
|
|
|
|
def test_the_series_is_capped():
|
|
state = MemoryState()
|
|
for i in range(HISTORY_LIMIT + 20):
|
|
state.append_history({"f.out": float(i)}, ts=float(i))
|
|
|
|
points = state.history("f.out")
|
|
assert len(points) == HISTORY_LIMIT
|
|
# The oldest twenty fell off the end.
|
|
assert points[0] == (20.0, 20.0)
|
|
assert points[-1] == (139.0, 139.0)
|
|
|
|
|
|
def test_only_numbers_are_recorded():
|
|
state = MemoryState()
|
|
state.append_history({"f.text": "warm", "f.flag": True, "f.temp": 21}, ts=1.0)
|
|
|
|
assert state.history("f.text") == []
|
|
assert state.history("f.flag") == []
|
|
assert state.history("f.temp") == [(1.0, 21.0)]
|
|
|
|
|
|
def test_a_message_without_history_comes_back_empty():
|
|
assert MemoryState().history("f.never") == []
|