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
202 lines
5.3 KiB
TypeScript
202 lines
5.3 KiB
TypeScript
import { useSyncExternalStore } from "react"
|
|
|
|
/**
|
|
* Live engine state, deliberately outside React Query.
|
|
*
|
|
* Values can arrive many times a second. Keeping them here, with one subscriber
|
|
* set per key, means a changing value re-renders the chip showing it and
|
|
* nothing else.
|
|
*/
|
|
|
|
/** Who caused a value. The canvas needs it to pulse the right edge. */
|
|
export type ValueSource = {
|
|
kind: "node" | "dashboard" | "flow" | "agent" | "api"
|
|
id: string
|
|
label: string
|
|
detail?: string
|
|
}
|
|
export type LiveValue = {
|
|
value: unknown
|
|
ts: number | null
|
|
source?: ValueSource
|
|
}
|
|
export type LiveStatus = {
|
|
status: "active" | "error" | "running" | "success"
|
|
error?: string | null
|
|
}
|
|
/** One node execution's output, as the log panel shows it. */
|
|
export type LogLine = {
|
|
flow: string
|
|
node: string
|
|
text: string
|
|
level: "info" | "error"
|
|
truncated?: boolean
|
|
ts: number
|
|
}
|
|
|
|
type Listener = () => void
|
|
|
|
/** Enough to see what a flow has been doing, not a log store. */
|
|
const LOG_LIMIT = 500
|
|
|
|
const values = new Map<string, LiveValue>()
|
|
const statuses = new Map<string, LiveStatus>()
|
|
// How many times a node has emitted. The number itself means nothing; a change
|
|
// is what restarts the pulse.
|
|
const emits = new Map<string, number>()
|
|
let logLines: LogLine[] = []
|
|
const paused = new Set<string>()
|
|
const listeners = new Map<string, Set<Listener>>()
|
|
|
|
let connected = false
|
|
const connectionListeners = new Set<Listener>()
|
|
|
|
function notify(key: string) {
|
|
for (const listener of listeners.get(key) ?? []) listener()
|
|
}
|
|
|
|
function subscribeKey(key: string, listener: Listener) {
|
|
let set = listeners.get(key)
|
|
if (!set) {
|
|
set = new Set()
|
|
listeners.set(key, set)
|
|
}
|
|
set.add(listener)
|
|
return () => {
|
|
set.delete(listener)
|
|
if (set.size === 0) listeners.delete(key)
|
|
}
|
|
}
|
|
|
|
export const liveStore = {
|
|
setValue(name: string, value: LiveValue) {
|
|
values.set(name, value)
|
|
notify(`value:${name}`)
|
|
},
|
|
setValues(entries: Record<string, LiveValue>) {
|
|
for (const [name, value] of Object.entries(entries)) {
|
|
values.set(name, value)
|
|
notify(`value:${name}`)
|
|
}
|
|
},
|
|
getValue(name: string) {
|
|
return values.get(name)
|
|
},
|
|
setStatus(nodeId: string, status: LiveStatus) {
|
|
statuses.set(nodeId, status)
|
|
notify(`status:${nodeId}`)
|
|
},
|
|
setStatuses(
|
|
entries: { id: string; status: string; error?: string | null }[],
|
|
) {
|
|
for (const entry of entries) {
|
|
statuses.set(entry.id, {
|
|
status: entry.status as LiveStatus["status"],
|
|
error: entry.error,
|
|
})
|
|
notify(`status:${entry.id}`)
|
|
}
|
|
},
|
|
getStatus(nodeId: string) {
|
|
return statuses.get(nodeId)
|
|
},
|
|
recordEmit(nodeId: string) {
|
|
emits.set(nodeId, (emits.get(nodeId) ?? 0) + 1)
|
|
notify(`emit:${nodeId}`)
|
|
},
|
|
appendLog(line: LogLine) {
|
|
// A new array each time, so the hook's snapshot comparison sees the change.
|
|
logLines = [...logLines, line].slice(-LOG_LIMIT)
|
|
notify("logs")
|
|
},
|
|
setLogs(lines: LogLine[]) {
|
|
logLines = lines.slice(-LOG_LIMIT)
|
|
notify("logs")
|
|
},
|
|
clearLogs() {
|
|
logLines = []
|
|
notify("logs")
|
|
},
|
|
setPaused(flow: string, isPaused: boolean) {
|
|
if (isPaused) paused.add(flow)
|
|
else paused.delete(flow)
|
|
notify(`paused:${flow}`)
|
|
},
|
|
setPausedFlows(flows: string[]) {
|
|
const next = new Set(flows)
|
|
for (const flow of new Set([...paused, ...next])) {
|
|
if (paused.has(flow) === next.has(flow)) continue
|
|
if (next.has(flow)) paused.add(flow)
|
|
else paused.delete(flow)
|
|
notify(`paused:${flow}`)
|
|
}
|
|
},
|
|
setConnected(next: boolean) {
|
|
if (connected === next) return
|
|
connected = next
|
|
for (const listener of connectionListeners) listener()
|
|
},
|
|
isConnected() {
|
|
return connected
|
|
},
|
|
reset() {
|
|
for (const key of values.keys()) notify(`value:${key}`)
|
|
values.clear()
|
|
for (const key of statuses.keys()) notify(`status:${key}`)
|
|
statuses.clear()
|
|
for (const key of emits.keys()) notify(`emit:${key}`)
|
|
emits.clear()
|
|
logLines = []
|
|
notify("logs")
|
|
for (const flow of paused) notify(`paused:${flow}`)
|
|
paused.clear()
|
|
},
|
|
}
|
|
|
|
export function useLiveValue(name: string | undefined): LiveValue | undefined {
|
|
return useSyncExternalStore(
|
|
(listener) => (name ? subscribeKey(`value:${name}`, listener) : () => {}),
|
|
() => (name ? values.get(name) : undefined),
|
|
)
|
|
}
|
|
|
|
export function useNodeStatus(nodeId: string): LiveStatus | undefined {
|
|
return useSyncExternalStore(
|
|
(listener) => subscribeKey(`status:${nodeId}`, listener),
|
|
() => statuses.get(nodeId),
|
|
)
|
|
}
|
|
|
|
/** Increments each time the node publishes something. */
|
|
export function useNodeEmits(nodeId: string): number {
|
|
return useSyncExternalStore(
|
|
(listener) => subscribeKey(`emit:${nodeId}`, listener),
|
|
() => emits.get(nodeId) ?? 0,
|
|
)
|
|
}
|
|
|
|
/** Every captured line, newest last. Filtered by the panel that shows it. */
|
|
export function useLiveLogs(): LogLine[] {
|
|
return useSyncExternalStore(
|
|
(listener) => subscribeKey("logs", listener),
|
|
() => logLines,
|
|
)
|
|
}
|
|
|
|
export function useFlowPaused(flow: string): boolean {
|
|
return useSyncExternalStore(
|
|
(listener) => subscribeKey(`paused:${flow}`, listener),
|
|
() => paused.has(flow),
|
|
)
|
|
}
|
|
|
|
export function useLiveConnection(): boolean {
|
|
return useSyncExternalStore(
|
|
(listener) => {
|
|
connectionListeners.add(listener)
|
|
return () => connectionListeners.delete(listener)
|
|
},
|
|
() => connected,
|
|
)
|
|
}
|