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
This commit is contained in:
@@ -17,6 +17,7 @@ from sqlmodel import Session
|
||||
from app.api.deps import FlowControllerDep, get_current_user, user_from_token
|
||||
from app.core.db import engine
|
||||
from app.flow.controller import FlowController
|
||||
from app.flow.dashboards import DashboardStore
|
||||
from app.flow.events import event_bus
|
||||
from app.flow.messages import qualify
|
||||
from app.flow.pipeline import ValidationIssue
|
||||
@@ -52,6 +53,27 @@ router = APIRouter(
|
||||
ws_router = APIRouter(prefix="/flows", tags=["flows"])
|
||||
|
||||
|
||||
class Endpoint(BaseModel):
|
||||
"""Something wired into this flow that is not a node in it.
|
||||
|
||||
A dashboard control setting one of its messages, a tile showing one, or a
|
||||
node in another flow on the far side of a dotted name. The canvas draws
|
||||
these so a value never appears to come from nowhere — or worse, appears to
|
||||
come from whichever node happens to be drawn as a producer.
|
||||
"""
|
||||
|
||||
#: dashboard or flow.
|
||||
kind: str
|
||||
#: Stable within its kind, and used as the canvas node id.
|
||||
id: str
|
||||
label: str
|
||||
#: What sort of widget, or the node type in the other flow.
|
||||
detail: str = ""
|
||||
#: Messages of this flow it publishes, and ones it reads.
|
||||
provides: list[str] = []
|
||||
requires: list[str] = []
|
||||
|
||||
|
||||
class FlowDetail(BaseModel):
|
||||
"""A flow plus how it is currently doing.
|
||||
|
||||
@@ -66,6 +88,8 @@ class FlowDetail(BaseModel):
|
||||
has_draft: bool = False
|
||||
enabled: bool = True
|
||||
paused: bool = False
|
||||
#: Dashboards and other flows wired into this one.
|
||||
endpoints: list[Endpoint] = []
|
||||
|
||||
|
||||
class ValidationResult(BaseModel):
|
||||
@@ -92,12 +116,45 @@ class TriggerRequest(BaseModel):
|
||||
values: dict[str, Any] = {}
|
||||
|
||||
|
||||
def _endpoints(controller: FlowController, flow: str) -> list[Endpoint]:
|
||||
"""Everything wired into ``flow`` from outside it."""
|
||||
found: list[Endpoint] = []
|
||||
|
||||
dashboards: DashboardStore | None = getattr(controller, "dashboards", None)
|
||||
if dashboards is not None:
|
||||
for binding in dashboards.bindings_for(flow):
|
||||
found.append(
|
||||
Endpoint(
|
||||
kind="dashboard",
|
||||
id=f"dashboard:{binding['dashboard']}:{binding['widget']}",
|
||||
label=binding["title"],
|
||||
detail=binding["type"],
|
||||
provides=[binding["provides"]] if binding["provides"] else [],
|
||||
requires=binding["requires"],
|
||||
)
|
||||
)
|
||||
|
||||
for other, node_id, provides, requires in controller.cross_flow_nodes(flow):
|
||||
found.append(
|
||||
Endpoint(
|
||||
kind="flow",
|
||||
id=f"flow:{other}.{node_id}",
|
||||
label=f"{other}.{node_id}",
|
||||
detail="flow",
|
||||
provides=provides,
|
||||
requires=requires,
|
||||
)
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
def _detail(controller: FlowController, definition: FlowDef) -> FlowDetail:
|
||||
name = definition.name
|
||||
running = {
|
||||
"enabled": controller.is_enabled(name),
|
||||
"paused": controller.is_paused(name),
|
||||
}
|
||||
endpoints = _endpoints(controller, name)
|
||||
if controller.store.has_draft(name):
|
||||
# Report the draft the editor is showing, not the version running
|
||||
# underneath it — otherwise a node the author just broke looks fine.
|
||||
@@ -107,12 +164,14 @@ def _detail(controller: FlowController, definition: FlowDef) -> FlowDetail:
|
||||
nodes=preview.nodes,
|
||||
issues=preview.issues,
|
||||
has_draft=True,
|
||||
endpoints=endpoints,
|
||||
**running,
|
||||
)
|
||||
return FlowDetail(
|
||||
definition=definition,
|
||||
nodes=controller.node_statuses(name),
|
||||
issues=controller.flow_issues(name),
|
||||
endpoints=endpoints,
|
||||
**running,
|
||||
)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ 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(
|
||||
@@ -43,6 +44,12 @@ class MessagesPublic(BaseModel):
|
||||
|
||||
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):
|
||||
@@ -73,8 +80,16 @@ async def publish_message(
|
||||
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)
|
||||
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}'"
|
||||
|
||||
Reference in New Issue
Block a user