Files
app/backend/app/api/routes/messages.py
T
stroblmeandClaude Fable 5 75c26ef000 Say what caused a value, and draw what is not a node
Moving a dashboard slider lit up an edge between two nodes that had done
nothing. The canvas pulsed on the message's timestamp alone, and a message
has no idea who published it — so it credited whichever node happened to
be drawn as a producer.

That was never only about dashboards. Two nodes producing one message
pulsed both their edges whichever fired, and a message produced in another
flow changed with nothing on screen to account for it at all.

Values now carry their cause: a node, a dashboard widget, another flow, an
agent or an API caller. An edge pulses only for the producer that actually
published, and the edge inspector says where a value came from when it did
not come from a node.

What is not a node in this flow is now drawn as one — a label rather than
a card, because a dashboard with twenty tiles would otherwise bury the
logic the canvas exists to show. That covers cross-flow wiring too, which
is the link in/out affordance that has been missing.

They are never part of the document. They join at render, after everything
that reads or writes the canvas nodes, so an autosave, an undo or a delete
cannot reach them — with a Playwright test that drags a node and asserts
the stored flow still holds exactly what it did.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
2026-08-16 15:51:54 +02:00

117 lines
3.7 KiB
Python

"""Messages: what a dashboard binds to, across every flow.
The flow API is scoped to one flow, which is the wrong shape here — a wall
panel shows the heating alongside the solar. These endpoints are the whole
namespace at once: what exists, what it last was, and a way to put a value
into it without owning a node.
"""
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel
from app.api.deps import FlowControllerDep, get_current_user
from app.flow.messages import flow_of
from app.flow.pipeline import ValueSource
from app.flow.state import as_number
router = APIRouter(
prefix="/messages", tags=["messages"], dependencies=[Depends(get_current_user)]
)
class MessageInfo(BaseModel):
"""One message, as something to bind a widget to."""
name: str
flow: str
dtype: str
#: Nodes publishing it. Empty means the flow declares it as an input.
providers: list[str] = []
#: Whether a dashboard may publish to it — that is, whether it is declared.
writable: bool = True
numeric: bool = False
value: Any = None
ts: float | None = None
class MessagesPublic(BaseModel):
data: list[MessageInfo]
count: int
class PublishRequest(BaseModel):
value: Any
#: Where this came from, so the canvas can show it arriving from outside
#: rather than crediting whichever node is drawn as a producer.
source_kind: str = "api"
source_id: str = ""
source_label: str = ""
source_detail: str = ""
class MessageValue(BaseModel):
name: str
value: Any
ts: float | None = None
class MessagePoints(BaseModel):
message: str
numeric: bool
points: list[dict[str, float]]
@router.get("/", response_model=MessagesPublic)
async def read_messages(controller: FlowControllerDep) -> Any:
"""Every message any published flow declares, with its last value."""
infos = await run_in_threadpool(controller.message_catalog)
return MessagesPublic(data=infos, count=len(infos))
@router.post("/{name}", response_model=MessageValue)
async def publish_message(
name: str, body: PublishRequest, controller: FlowControllerDep
) -> Any:
"""Put a value into the graph, as a dashboard control does.
Only a message some flow declares can be published to: flows own the
namespace, and a dashboard is a client of it rather than a second author.
"""
source = ValueSource(
kind=body.source_kind,
id=body.source_id,
label=body.source_label or body.source_id or "API",
detail=body.source_detail,
)
try:
await run_in_threadpool(
controller.publish_message, name, body.value, source
)
except KeyError:
raise HTTPException(
status_code=404, detail=f"No flow declares a message named '{name}'"
)
except TypeError as exc:
raise HTTPException(status_code=422, detail=str(exc))
values = controller.values(flow_of(name))
current = values.get(name, {})
return MessageValue(name=name, value=current.get("value"), ts=current.get("ts"))
@router.get("/{name}/history", response_model=MessagePoints)
def read_message_history(name: str, controller: FlowControllerDep) -> Any:
"""The series behind a chart. Numbers only — nothing else plots."""
if controller.pipeline is None:
return MessagePoints(message=name, numeric=False, points=[])
series = controller.state.history(name)
numeric = as_number(controller.state.get(name)) is not None
return MessagePoints(
message=name,
numeric=numeric or bool(series),
points=[{"ts": ts, "value": value} for ts, value in series],
)