import { useCallback, useRef, useSyncExternalStore } from "react" import { OpenAPI } from "@/client" import { request } from "@/client/core/request" /** * 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" | "chat" | "api" | "panel" id: string label: string detail?: string } export type LiveValue = { value: unknown ts: number | null source?: ValueSource /** * Caught up from a snapshot rather than seen arriving. It happened, but not * while anyone was watching — the same distinction as `priorEmits`. */ prior?: boolean } export type LiveStatus = { status: "active" | "error" | "queued" | "running" | "success" error?: string | null /** Why it is queued — what it is waiting for, and on which machine. */ detail?: string | null } /** * A node's last failure, kept after it has run again. * * `LiveStatus` answers "how did the last run go", which is what the red dot * has to keep telling the truth about. This answers the other question — did * this node fail at all since anyone looked — and the engine answers it too, * so a reload and a second browser see the same thing. Only acknowledging one * clears it. */ export type NodeFailure = { error: string; ts: number } /** Something the engine reported about itself, for the health page. */ export type EngineEvent = { type: string flow?: string node?: string detail?: string ts: number } /** 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 /** The health page reads these to know when to refetch; it is not a history. */ const ENGINE_EVENT_LIMIT = 100 const values = new Map() const statuses = new Map() const failures = new Map() let engineEvents: EngineEvent[] = [] // How many times this page has seen a node emit. The number itself means // nothing; a change is what restarts the pulse. const emits = new Map() // What the engine had already counted when we connected, from the snapshot. // Kept apart from the live count on purpose: it says the node has been busy, // which is worth drawing, but it happened before anyone was watching, and a // pulse claims something is happening now. const priorEmits = new Map() let logLines: LogLine[] = [] const paused = new Set() const listeners = new Map>() let connected = false const connectionListeners = new Set() /** * Object URLs for frames the engine pushed, keyed by digest. * * Small on purpose: a camera's frames are worth exactly as long as the next * one takes to arrive, and a tab that held every one of them would grow * without bound. The oldest is revoked when the room runs out. */ const BYTES_LIMIT = 8 const bytes = new Map() /** * Which messages this page is drawing bytes for, and how many tiles each. * * Refcounted because two tiles may show one camera and the first to unmount * must not stop the second's frames. The socket sends the key set whenever it * changes; nothing is pushed for a name nobody is looking at. */ const wanted = new Map() const wantedListeners = new Set() 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 = { /** Told of every change to one key, such as `value:`, as it happens. */ subscribe: subscribeKey, setValue(name: string, value: LiveValue) { values.set(name, value) notify(`value:${name}`) }, setValues(entries: Record) { for (const [name, value] of Object.entries(entries)) { values.set(name, { ...value, prior: true }) notify(`value:${name}`) } }, getValue(name: string) { return values.get(name) }, setStatus(nodeId: string, status: LiveStatus) { statuses.set(nodeId, status) notify(`status:${nodeId}`) // Recorded here rather than waited for: the engine keeps the same record, // but the canvas has to mark the failure as it happens. A node only ever // reaches this store as "error" by having raised. if (status.status === "error") { failures.set(nodeId, { error: status.error || "The node raised while running.", ts: Date.now() / 1000, }) notify(`failure:${nodeId}`) } }, setStatuses( entries: { id: string status: string error?: string | null /** The engine's own record of the last failure — see `NodeFailure`. */ last_error?: string | null last_error_ts?: number | null }[], ) { for (const entry of entries) { statuses.set(entry.id, { status: entry.status as LiveStatus["status"], error: entry.error, }) notify(`status:${entry.id}`) // The engine's record wins, in both directions: it is what a reload // reads, and an empty one means someone has acknowledged the failure. if (entry.last_error) { failures.set(entry.id, { error: entry.last_error, ts: entry.last_error_ts ?? Date.now() / 1000, }) notify(`failure:${entry.id}`) } else if (failures.delete(entry.id)) { notify(`failure:${entry.id}`) } } }, getStatus(nodeId: string) { return statuses.get(nodeId) }, getFailure(nodeId: string) { return failures.get(nodeId) }, /** Drop the marker for a failure the engine has already been told about. */ clearFailure(nodeId: string) { if (failures.delete(nodeId)) notify(`failure:${nodeId}`) }, /** * Dismiss a node's failure, here and on the engine. * * The only thing that clears one: a good run afterwards deliberately does * not, which is what makes a failure between two glances at the canvas * findable at all. */ acknowledgeFailure(nodeId: string) { if (!failures.delete(nodeId)) return notify(`failure:${nodeId}`) const [flow, node] = nodeId.split(".") if (!flow || !node) return // Hand-written rather than generated: nothing hangs off the response, and // a call that does not arrive only means the marker is back after a // reload — which is the safer way round for a failure. request(OpenAPI, { method: "POST", url: "/api/v1/flows/{name}/nodes/{node_id}/acknowledge", path: { name: flow, node_id: node }, }).catch(() => {}) }, recordEngineEvent(event: EngineEvent) { // A new array each time, so the hook's snapshot comparison sees the change. engineEvents = [...engineEvents, event].slice(-ENGINE_EVENT_LIMIT) notify("engine") }, recordEmit(nodeId: string) { emits.set(nodeId, (emits.get(nodeId) ?? 0) + 1) notify(`emit:${nodeId}`) }, /** * The counts a snapshot carries, so a page that connects into an engine * already at work draws what has been busy rather than a graph of idle * neurons. * * Absent keys are left alone rather than cleared: a restarted engine counts * from zero again, which is no reason to forget what this page saw. */ setEmits(entries: Record) { for (const [nodeId, count] of Object.entries(entries)) { if (priorEmits.get(nodeId) === count) continue priorEmits.set(nodeId, count) 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 }, /** * Take a frame the socket pushed, as an object URL the tile can draw. * * Content addressing means a digest already here is the same bytes, so the * blob is dropped rather than replacing an identical one. */ setBytes(digest: string, blob: Blob) { if (bytes.has(digest)) return bytes.set(digest, URL.createObjectURL(blob)) while (bytes.size > BYTES_LIMIT) { const oldest = bytes.keys().next().value if (oldest === undefined) break const url = bytes.get(oldest) bytes.delete(oldest) if (url) URL.revokeObjectURL(url) notify(`bytes:${oldest}`) } notify(`bytes:${digest}`) }, getBytes(digest: string) { return bytes.get(digest) }, /** Ask for this message's frames while the caller is drawing it. */ wantBytes(name: string) { wanted.set(name, (wanted.get(name) ?? 0) + 1) if (wanted.get(name) === 1) for (const listener of wantedListeners) listener() return () => { const left = (wanted.get(name) ?? 1) - 1 if (left > 0) { wanted.set(name, left) return } wanted.delete(name) for (const listener of wantedListeners) listener() } }, wantedNames() { return [...wanted.keys()] }, /** Told when the set changes, so the socket can say so. */ onWanted(listener: Listener) { wantedListeners.add(listener) return () => { wantedListeners.delete(listener) } }, 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 failures.keys()) notify(`failure:${key}`) failures.clear() for (const key of new Set([...emits.keys(), ...priorEmits.keys()])) notify(`emit:${key}`) emits.clear() priorEmits.clear() engineEvents = [] notify("engine") logLines = [] notify("logs") for (const flow of paused) notify(`paused:${flow}`) paused.clear() for (const [digest, url] of bytes) { URL.revokeObjectURL(url) notify(`bytes:${digest}`) } bytes.clear() // `wanted` is deliberately kept: the tiles asking are still mounted, and a // reconnecting socket has to say what they want all over again. }, } export function useLiveValue(name: string | undefined): LiveValue | undefined { return useSyncExternalStore( (listener) => (name ? subscribeKey(`value:${name}`, listener) : () => {}), () => (name ? values.get(name) : undefined), ) } /** The pushed bytes for a digest, if the socket carried them. */ export function useLiveBytes(digest: string | undefined): string | undefined { return useSyncExternalStore( (listener) => digest ? subscribeKey(`bytes:${digest}`, listener) : () => {}, () => (digest ? bytes.get(digest) : undefined), ) } export function useNodeStatus(nodeId: string): LiveStatus | undefined { return useSyncExternalStore( (listener) => subscribeKey(`status:${nodeId}`, listener), () => statuses.get(nodeId), ) } /** The node's last failure, until someone acknowledges it. */ export function useNodeFailure(nodeId: string): NodeFailure | undefined { return useSyncExternalStore( (listener) => subscribeKey(`failure:${nodeId}`, listener), () => failures.get(nodeId), ) } /** The last hundred things the engine said about itself, oldest first. */ export function useEngineEvents(): EngineEvent[] { return useSyncExternalStore( (listener) => subscribeKey("engine", listener), () => engineEvents, ) } /** * The minute the newest engine event landed in, or 0 for none yet. * * A scalar, so a subscriber that only wants "has something gone wrong * lately" re-renders when the answer changes rather than on every event. * `recordEngineEvent` allocates a new array each time, so subscribing to the * array itself means a flapping node re-renders whatever is watching — which * on Home is two uPlot charts that rebuild their series from scratch. */ export function useEngineEventMinute(): number { return useSyncExternalStore( (listener) => subscribeKey("engine", listener), () => engineEvents.length ? Math.floor(engineEvents[engineEvents.length - 1].ts / 60) : 0, ) } /** 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), ) } /* * Group hooks, for the brain graph: one neuron stands for several nodes, and * one edge for several messages. * * Each returns a primitive. `useSyncExternalStore` compares snapshots by * identity, so a fresh object or array every render would re-render forever. * The names arrive as an array, which is also new every render, so they are * joined into one string before anything hangs off them. */ const SEP = "\u0000" /** Back to the array. `"".split(SEP)` is `[""]`, which is one empty name. */ const parts = (joined: string) => (joined ? joined.split(SEP) : []) function subscribeAll(keys: string[], listener: Listener) { const unsubscribes = keys.map((key) => subscribeKey(key, listener)) return () => { for (const unsubscribe of unsubscribes) unsubscribe() } } /** How many times any of these nodes has published. A change is a pulse. */ export function useGroupEmits(ids: string[]): number { const joined = ids.join(SEP) return useSyncExternalStore( useCallback( (listener: Listener) => subscribeAll( parts(joined).map((id) => `emit:${id}`), listener, ), [joined], ), () => parts(joined).reduce((total, id) => total + (emits.get(id) ?? 0), 0), ) } /** * Whether any of these nodes has ever published, this page or before it. * * Separate from the count above because it answers a different question: the * count is what pulses, and this is what tells a neuron nobody has ever seen * run from one that is merely quiet just now. */ export function useGroupActive(ids: string[]): boolean { const joined = ids.join(SEP) return useSyncExternalStore( useCallback( (listener: Listener) => subscribeAll( parts(joined).map((id) => `emit:${id}`), listener, ), [joined], ), () => parts(joined).some( (id) => (emits.get(id) ?? 0) + (priorEmits.get(id) ?? 0) > 0, ), ) } /** * What any of these nodes last failed with, or `""` for none. * * The failure rather than the live status: a neuron stands for nodes across * several flows, and Home would otherwise disagree with the canvas the moment * one of them ran again. The reason itself, because a string is a snapshot * `useSyncExternalStore` can compare and the neuron shows it in its tooltip. */ export function useGroupFailure(ids: string[]): string { const joined = ids.join(SEP) return useSyncExternalStore( useCallback( (listener: Listener) => subscribeAll( parts(joined).map((id) => `failure:${id}`), listener, ), [joined], ), () => parts(joined) .map((id) => failures.get(id)?.error ?? "") .find(Boolean) ?? "", ) } /** When any of these messages last carried a value. */ export function useLatestTs(names: string[]): number { const joined = names.join(SEP) return useSyncExternalStore( useCallback( (listener: Listener) => subscribeAll( parts(joined).map((name) => `value:${name}`), listener, ), [joined], ), () => parts(joined).reduce( (latest, name) => Math.max(latest, values.get(name)?.ts ?? 0), 0, ), ) } /** * Several messages at once, for a widget whose bindings are a list. * * `useLiveValue` is one hook per name, and hooks cannot be called in a loop — * which is why a chart used to call it five times and a bar three, whatever * the document actually bound. This subscribes to as many names as it is * given, so the widget's shape follows its configuration instead of the other * way round. * * The snapshot must keep its identity while nothing has changed: * `useSyncExternalStore` compares by reference, and a fresh array every render * would re-render for ever. */ export function useLiveValues(names: string[]): (LiveValue | undefined)[] { const joined = names.join(SEP) const last = useRef<(LiveValue | undefined)[]>([]) return useSyncExternalStore( useCallback( (listener: Listener) => subscribeAll( parts(joined).map((name) => `value:${name}`), listener, ), [joined], ), () => { const next = parts(joined).map((name) => name ? values.get(name) : undefined, ) const kept = last.current if (next.length === kept.length && next.every((v, i) => v === kept[i])) { return kept } last.current = next return next }, ) } export function useLiveConnection(): boolean { return useSyncExternalStore( (listener) => { connectionListeners.add(listener) return () => connectionListeners.delete(listener) }, () => connected, ) }