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
155 lines
4.8 KiB
TypeScript
155 lines
4.8 KiB
TypeScript
import { useQueryClient } from "@tanstack/react-query"
|
|
import { useEffect, useRef } from "react"
|
|
|
|
import { OpenAPI } from "@/client"
|
|
import { type LogLine, liveStore, type ValueSource } from "./liveStore"
|
|
import { flowKeys } from "./queries"
|
|
|
|
const RECONNECT_MIN = 1000
|
|
|
|
/** How many components want the socket open. */
|
|
let mounted = 0
|
|
const RECONNECT_MAX = 30000
|
|
|
|
type FlowEvent =
|
|
| {
|
|
type: "snapshot"
|
|
values: Record<string, { value: unknown; ts: number | null }>
|
|
nodes: { id: string; status: string; error?: string | null }[]
|
|
paused?: string[]
|
|
logs?: LogLine[]
|
|
}
|
|
| {
|
|
type: "message_value"
|
|
name: string
|
|
value: unknown
|
|
ts: number
|
|
source?: ValueSource
|
|
}
|
|
| { type: "node_executed"; node: string; outputs: number }
|
|
| { type: "node_error"; node: string; error: string }
|
|
| { type: "node_status"; node: string; status: string; error?: string | null }
|
|
| ({ type: "node_log" } & LogLine)
|
|
| { type: "flow_paused"; flow: string; paused: boolean }
|
|
| {
|
|
type: "pipeline_rebuilt"
|
|
nodes: { id: string; status: string; error?: string | null }[]
|
|
paused?: string[]
|
|
}
|
|
|
|
function socketUrl(): string {
|
|
const base = String(OpenAPI.BASE || window.location.origin)
|
|
const url = new URL("/api/v1/flows/ws", base)
|
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
|
// Browsers cannot set headers on a websocket handshake, so the token rides
|
|
// in the query string.
|
|
url.searchParams.set("token", localStorage.getItem("access_token") ?? "")
|
|
return url.toString()
|
|
}
|
|
|
|
/**
|
|
* Keeps one socket open for the editor, feeding the live store.
|
|
*
|
|
* @param onAuthFailure called when the server rejects the token, so the caller
|
|
* can send the user back to the login screen.
|
|
*/
|
|
export function useFlowSocket(onAuthFailure?: () => void): void {
|
|
const socket = useRef<WebSocket | null>(null)
|
|
const retry = useRef(RECONNECT_MIN)
|
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const closed = useRef(false)
|
|
const queryClient = useQueryClient()
|
|
|
|
useEffect(() => {
|
|
// The editor and a dashboard can both be mounted; one socket serves both,
|
|
// and the second caller just rides along.
|
|
mounted += 1
|
|
if (mounted > 1) {
|
|
return () => {
|
|
mounted -= 1
|
|
}
|
|
}
|
|
closed.current = false
|
|
|
|
const connect = () => {
|
|
if (closed.current) return
|
|
const ws = new WebSocket(socketUrl())
|
|
socket.current = ws
|
|
|
|
ws.onopen = () => {
|
|
retry.current = RECONNECT_MIN
|
|
liveStore.setConnected(true)
|
|
}
|
|
|
|
ws.onmessage = (event) => {
|
|
const message: FlowEvent = JSON.parse(event.data)
|
|
switch (message.type) {
|
|
case "snapshot":
|
|
liveStore.setValues(message.values)
|
|
liveStore.setStatuses(message.nodes)
|
|
liveStore.setPausedFlows(message.paused ?? [])
|
|
liveStore.setLogs(message.logs ?? [])
|
|
break
|
|
case "message_value":
|
|
liveStore.setValue(message.name, {
|
|
value: message.value,
|
|
ts: message.ts,
|
|
source: message.source,
|
|
})
|
|
break
|
|
case "node_executed":
|
|
liveStore.setStatus(message.node, { status: "success" })
|
|
if (message.outputs > 0) liveStore.recordEmit(message.node)
|
|
break
|
|
case "node_error":
|
|
liveStore.setStatus(message.node, {
|
|
status: "error",
|
|
error: message.error,
|
|
})
|
|
break
|
|
case "node_status":
|
|
liveStore.setStatus(message.node, {
|
|
status: message.status as "active" | "error",
|
|
error: message.error,
|
|
})
|
|
break
|
|
case "node_log":
|
|
liveStore.appendLog(message)
|
|
break
|
|
case "flow_paused":
|
|
liveStore.setPaused(message.flow, message.paused)
|
|
break
|
|
case "pipeline_rebuilt":
|
|
liveStore.setStatuses(message.nodes)
|
|
liveStore.setPausedFlows(message.paused ?? [])
|
|
// Someone published or started a flow, here or in another tab: the
|
|
// markers on the flow chips are stale until the list is refetched.
|
|
queryClient.invalidateQueries({ queryKey: flowKeys.all })
|
|
break
|
|
}
|
|
}
|
|
|
|
ws.onclose = (event) => {
|
|
liveStore.setConnected(false)
|
|
if (closed.current) return
|
|
if (event.code === 1008) {
|
|
onAuthFailure?.()
|
|
return
|
|
}
|
|
timer.current = setTimeout(connect, retry.current)
|
|
retry.current = Math.min(retry.current * 2, RECONNECT_MAX)
|
|
}
|
|
}
|
|
|
|
connect()
|
|
|
|
return () => {
|
|
mounted -= 1
|
|
closed.current = true
|
|
if (timer.current) clearTimeout(timer.current)
|
|
socket.current?.close()
|
|
liveStore.setConnected(false)
|
|
}
|
|
}, [onAuthFailure, queryClient])
|
|
}
|