import type { Edge } from "@xyflow/react" import type { MessageSpec, NodeDef_Input } from "@/client" import type { Route } from "./layout" /** Resolve a message name against its flow: bare names belong to this flow. */ export function qualify(flow: string, name: string): string { if (!name) return "" return name.includes(".") ? name : `${flow}.${name}` } /** * A message named with its flow, as the server checks it wherever a name has * to arrive qualified (`QUALIFIED_PATTERN` in `flow/messages.py`). */ const QUALIFIED_MESSAGE = /^[a-z][a-z0-9_]*(\.[A-Za-z0-9_]+)+$/ /** What is wrong with a name that has to be qualified, or null. */ export function qualifiedProblem(name: string): string | null { return QUALIFIED_MESSAGE.test(name) ? null : "Name the message as flow.name: a lowercase flow name, a dot, then letters, digits and underscores" } /** The flow a qualified message belongs to. */ export function flowOf(message: string): string { return message.split(".", 1)[0] } /** How a message reads inside its own flow. */ export function displayName(flow: string, message: string): string { return message.startsWith(`${flow}.`) ? message.slice(flow.length + 1) : message } export function portOf(spec: MessageSpec): string { return spec.port || spec.name?.split(".").pop() || "" } export type FlowEdgeData = { message: string flow: string /** Whose publication this edge represents, so only it pulses. */ producerId: string /** The producing port publishes repeatedly during one run, not once at its end. */ stream?: boolean /** How the layout routed it, once it has. */ route?: Route [key: string]: unknown } /** * Turn name bindings into canvas edges. * * Nodes never point at each other: a node declares the messages it consumes, * and every node providing that message is upstream of it. Two producers of one * message therefore draw two edges converging on the same input. */ export function deriveEdges(nodes: NodeDef_Input[], flow: string): Edge[] { const producers = new Map< string, { node: string; port: string; stream?: boolean }[] >() for (const node of nodes) { for (const spec of node.provides ?? []) { const message = qualify(flow, spec.name ?? "") if (!message) continue const list = producers.get(message) ?? [] list.push({ node: node.id, port: portOf(spec), stream: spec.stream }) producers.set(message, list) } } const edges: Edge[] = [] for (const node of nodes) { for (const spec of node.requires ?? []) { const message = qualify(flow, spec.name ?? "") if (!message) continue const targetPort = portOf(spec) for (const producer of producers.get(message) ?? []) { if (producer.node === node.id) continue edges.push({ id: `${producer.node}:${producer.port}->${node.id}:${targetPort}`, source: producer.node, sourceHandle: producer.port, target: node.id, targetHandle: targetPort, type: "live", data: { message, flow, producerId: `${flow}.${producer.node}`, stream: producer.stream, } satisfies FlowEdgeData, }) } } } return edges } /** * A cheap fingerprint of the flow's boundary — what it takes from outside and, * for a batch flow, what it reports — since those are drawn like wiring too. */ export function boundaryKey(doc: { mode?: string | null inputs?: { spec?: MessageSpec | null; origin?: string }[] | null outputs?: string[] | null }): string { const inputs = (doc.inputs ?? []) .map( (one) => `${one.spec?.name ?? ""}:${one.spec?.dtype ?? ""}:${one.origin ?? ""}`, ) .join(",") return `${doc.mode ?? "live"}|${inputs}|${(doc.outputs ?? []).join(",")}` } /** * A cheap fingerprint of everything edges depend on, so dragging a node (which * replaces the array every frame) does not re-derive them. */ export function bindingsKey(nodes: NodeDef_Input[]): string { return nodes .map( (node) => `${node.id}|${(node.requires ?? []) .map((s) => `${portOf(s)}=${s.name ?? ""}`) .join(",")}|${(node.provides ?? []) .map((s) => `${portOf(s)}=${s.name ?? ""}${s.stream ? "*" : ""}`) .join(",")}`, ) .join(";") }