Files
app/frontend/src/components/Flow/endpoints.ts
T
stroblmeandClaude Opus 5 2abeae7f9c Flow inputs and outputs are visible and editable in the UI
A flow's inputs are the messages it takes from outside — a dashboard control,
a run, the API — and its outputs are what a batch run reports. Both existed in
the document and in the engine, and neither had any UI: the values looked
hard-coded on the canvas and the Run button always used the declared defaults.

The canvas now draws each as a labelled endpoint, the way it already draws a
dashboard tile or another flow, skipping an input something else already
accounts for. The flow panel edits them — mode, name, type, starting value,
and for a live flow the value it currently holds with a way to put a new one
in. Pressing Run on a batch flow asks for its parameters first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUb8YpL2s3gmN9WTACTt4q
2026-08-20 18:10:16 +02:00

198 lines
6.1 KiB
TypeScript

import type { Edge, Node as FlowCanvasNode } from "@xyflow/react"
import type { Endpoint, FlowDef_Input, NodeDef_Input } from "@/client"
import type { FlowEdgeData } from "./deriveEdges"
import { displayName, portOf, qualify } from "./deriveEdges"
/**
* Canvas elements for things wired into a flow that are not nodes in it.
*
* A dashboard control setting one of its messages, a tile showing one, or a
* node in another flow across a dotted name. Without these the canvas shows a
* value changing with nothing to account for it — and pulses whichever node
* happens to be drawn as a producer, which did nothing.
*
* They are never part of the document: this returns display-only nodes that
* are appended after everything which reads or writes `canvasNodes`, so an
* autosave, an undo or a delete cannot reach them.
*/
/** Marks a canvas node as one of these, for anything that has to skip them. */
export const ENDPOINT_TYPE = "endpoint"
/** The id prefixes an endpoint may carry, none of which is a node id. */
const PREFIXES = ["dashboard:", "flow:", "input:", "output:"]
export type EndpointNodeData = {
label: string
kind: string
detail: string
/** Messages it publishes into this flow, and ones it reads out of it. */
provides: string[]
requires: string[]
[key: string]: unknown
}
export function isEndpointNode(node: { id: string }): boolean {
return PREFIXES.some((prefix) => node.id.startsWith(prefix))
}
/**
* The flow's own boundary, as endpoints.
*
* An input is a message written from outside — a control, a run, the API — and
* an output is what a batch run reports. They come from the working document
* rather than from the server, so declaring one shows up on the canvas before
* it has been published.
*
* An input a dashboard or another flow already accounts for is left out: the
* label exists so a value has a visible origin, and it already has one.
*/
function boundaryOf(
doc: FlowDef_Input,
flow: string,
accounted: Set<string>,
): Endpoint[] {
const found: Endpoint[] = []
for (const declared of doc.inputs ?? []) {
const message = qualify(flow, declared.spec?.name ?? "")
if (!message || accounted.has(message)) continue
found.push({
kind: "input",
id: `input:${message}`,
label: displayName(flow, message),
detail: declared.spec?.dtype ?? "float",
provides: [message],
requires: [],
})
}
// Only a batch flow has a result; a live one never finishes to have one.
if (doc.mode === "batch") {
for (const name of doc.outputs ?? []) {
const message = qualify(flow, name)
if (!message) continue
found.push({
kind: "output",
id: `output:${message}`,
label: displayName(flow, message),
detail: "result",
provides: [],
requires: [message],
})
}
}
return found
}
/**
* Build the endpoints and wire them to the nodes they touch.
*
* They carry no position: the caller lays them out together with the flow's
* own nodes (see `layout.ts`), so an endpoint that publishes lands upstream of
* what it feeds and one that reads lands downstream of what feeds it, by the
* same rule that orders everything else.
*/
export function deriveEndpoints(
endpoints: Endpoint[],
definitions: NodeDef_Input[],
flow: string,
doc: FlowDef_Input,
): { nodes: FlowCanvasNode[]; edges: Edge[] } {
const accounted = new Set(endpoints.flatMap((one) => one.provides ?? []))
const all = [...boundaryOf(doc, flow, accounted), ...endpoints]
if (all.length === 0) return { nodes: [], edges: [] }
// Which node consumes or produces each message, so a label can sit beside it.
const consumers = new Map<string, { node: string; port: string }[]>()
const producers = new Map<
string,
{ node: string; port: string; stream?: boolean }[]
>()
for (const node of definitions) {
for (const spec of node.requires ?? []) {
const message = qualify(flow, spec.name ?? "")
if (!message) continue
consumers.set(message, [
...(consumers.get(message) ?? []),
{ node: node.id, port: portOf(spec) },
])
}
for (const spec of node.provides ?? []) {
const message = qualify(flow, spec.name ?? "")
if (!message) continue
producers.set(message, [
...(producers.get(message) ?? []),
{ node: node.id, port: portOf(spec), stream: spec.stream },
])
}
}
const nodes: FlowCanvasNode[] = []
const edges: Edge[] = []
for (const endpoint of all) {
const produces = endpoint.provides ?? []
const reads = endpoint.requires ?? []
nodes.push({
id: endpoint.id,
type: ENDPOINT_TYPE,
// Filled in by the layout, along with the flow's own nodes.
position: { x: 0, y: 0 },
selectable: true,
deletable: false,
data: {
label: endpoint.label,
kind: endpoint.kind,
detail: endpoint.detail ?? "",
provides: produces,
requires: reads,
} satisfies EndpointNodeData,
})
// An endpoint that publishes feeds every node consuming that message.
for (const message of produces) {
for (const consumer of consumers.get(message) ?? []) {
edges.push({
id: `${endpoint.id}->${consumer.node}:${consumer.port}`,
source: endpoint.id,
sourceHandle: message,
target: consumer.node,
targetHandle: consumer.port,
type: "live",
data: {
message,
flow,
producerId: endpoint.id,
} satisfies FlowEdgeData,
})
}
}
// One that reads is fed by every node producing it.
for (const message of reads) {
for (const producer of producers.get(message) ?? []) {
edges.push({
id: `${producer.node}:${producer.port}->${endpoint.id}`,
source: producer.node,
sourceHandle: producer.port,
target: endpoint.id,
targetHandle: message,
type: "live",
data: {
message,
flow,
producerId: `${flow}.${producer.node}`,
stream: producer.stream,
} satisfies FlowEdgeData,
})
}
}
}
return { nodes, edges }
}