Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
511 lines
18 KiB
TypeScript
511 lines
18 KiB
TypeScript
import type { QueryClient } from "@tanstack/react-query"
|
|
import { useQueryClient } from "@tanstack/react-query"
|
|
import { useEffect } from "react"
|
|
|
|
import { ApiError, FlowsService, OpenAPI } from "@/client"
|
|
import { dashboardKeys, panelKeys } from "@/components/Dashboard/queries"
|
|
import { healthKeys } from "@/components/Health/queries"
|
|
import { runKeys } from "@/components/Runs/queries"
|
|
import { connectionStore } from "@/lib/connectionStore"
|
|
import { parseMediaFrame } from "@/lib/media"
|
|
import { type LogLine, liveStore, type ValueSource } from "./liveStore"
|
|
import { flowKeys } from "./queries"
|
|
|
|
const RECONNECT_MIN = 1000
|
|
const RECONNECT_MAX = 30000
|
|
/**
|
|
* Consecutive token rejections before the session is given up on.
|
|
*
|
|
* A 1008 means the server would not take the token. Under a portal that is
|
|
* routine — the handoff token lasts twelve hours against a local session's
|
|
* eight days — so it has to be recoverable rather than fatal: the token is read
|
|
* again for every attempt, so a refreshed one is picked up. A session that has
|
|
* genuinely been revoked fails all three and falls through to the caller's auth
|
|
* handler instead of retrying forever.
|
|
*/
|
|
const AUTH_ATTEMPTS = 3
|
|
/**
|
|
* How long the engine holds a node down before it surfaces as an issue.
|
|
*
|
|
* ponytail: a hand copy of `HEALTH_FLOOR` in
|
|
* `backend/fluksio/flow/controller.py:122`, which drifts silently if that one
|
|
* moves — nothing breaks, the border just turns early or late. Carrying the
|
|
* floor in the `node_health` payload is the upgrade path.
|
|
*/
|
|
const HEALTH_FLOOR = 60_000
|
|
|
|
type FlowEvent =
|
|
| {
|
|
type: "snapshot"
|
|
values: Record<string, { value: unknown; ts: number | null }>
|
|
nodes: { id: string; status: string; error?: string | null }[]
|
|
paused?: string[]
|
|
logs?: LogLine[]
|
|
/** Emission counts per qualified node. Absent on older instances. */
|
|
emits?: Record<string, number>
|
|
/** Nodes waiting for a machine. Absent on older instances. */
|
|
queued?: { node: string; reason: string; seconds: number }[]
|
|
}
|
|
| {
|
|
type: "message_value"
|
|
name: string
|
|
value: unknown
|
|
ts: number
|
|
source?: ValueSource
|
|
}
|
|
| { type: "node_started"; node: string }
|
|
| {
|
|
type: "node_queued"
|
|
flow?: string
|
|
node: string
|
|
run?: string
|
|
detail?: string
|
|
ts?: number
|
|
}
|
|
| {
|
|
type: "node_executed"
|
|
flow?: string
|
|
node: string
|
|
outputs: number
|
|
duration_ms?: number
|
|
}
|
|
| {
|
|
type: "node_error"
|
|
flow?: string
|
|
node: string
|
|
error: string
|
|
ts?: number
|
|
}
|
|
| { type: "node_status"; node: string; status: string; error?: string | null }
|
|
| { type: "node_error_acknowledged"; node: string; ts?: number }
|
|
| ({ type: "node_log" } & LogLine)
|
|
| { type: "flow_paused"; flow: string; paused: boolean }
|
|
| {
|
|
type: "node_health"
|
|
flow?: string
|
|
node: string
|
|
health: "ok" | "down" | "unknown"
|
|
detail?: string | null
|
|
ts?: number
|
|
}
|
|
| { type: "flow_quarantined"; flow: string; error?: string; ts?: number }
|
|
| { type: "engine_degraded"; reason?: string; ts?: number }
|
|
| { type: "engine_fatal"; reason?: string; ts?: number }
|
|
| {
|
|
type: "cascade_dropped"
|
|
flow?: string
|
|
node?: string
|
|
deliveries?: number
|
|
ts?: number
|
|
}
|
|
| {
|
|
type: "queue_unavailable"
|
|
flow?: string
|
|
node?: string
|
|
error?: string
|
|
ts?: number
|
|
}
|
|
| {
|
|
type: "pipeline_rebuilt"
|
|
nodes: { id: string; status: string; error?: string | null }[]
|
|
paused?: string[]
|
|
}
|
|
| { type: "dashboard_changed"; dashboard?: string; ts?: number }
|
|
| {
|
|
type: "run_started" | "run_finished"
|
|
flow: string
|
|
run: string
|
|
status?: string
|
|
group?: string
|
|
ts?: number
|
|
}
|
|
| {
|
|
type: "run_metric"
|
|
flow: string
|
|
run: string
|
|
names: string[]
|
|
ts?: number
|
|
}
|
|
|
|
async function socketUrl(): Promise<string> {
|
|
const base = String(OpenAPI.BASE || window.location.origin)
|
|
// Concatenated rather than resolved: an absolute path as the second argument
|
|
// to `new URL` discards the base's own path, which under a portal
|
|
// (`https://host/i/{id}`) would aim the socket at the wrong place entirely.
|
|
const url = new URL(`${base.replace(/\/$/, "")}/api/v1/flows/ws`)
|
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
|
// Browsers cannot set headers on a websocket handshake, and a token in the
|
|
// URL ends up in every access log on the way. So the token is traded for a
|
|
// one-time ticket, fetched per attempt: it is good for one connect, and the
|
|
// call carries whatever token is current.
|
|
const { ticket } = await FlowsService.issueSocketTicket()
|
|
url.searchParams.set("ticket", ticket)
|
|
return url.toString()
|
|
}
|
|
|
|
/*
|
|
* One socket for the page, owned here rather than by a component.
|
|
*
|
|
* Several places want live events at once — the app shell, the brain graph on
|
|
* Home, the editor — and React mounts and unmounts them in an order none of
|
|
* them controls: passive effects run children before parents, and StrictMode
|
|
* runs mount → unmount → mount. Keeping the socket inside whichever hook
|
|
* instance ran first made that one its owner, so leaving Home closed the socket
|
|
* while the shell kept the count above zero and nothing ever reopened it. The
|
|
* connection lives out here now with a plain refcount; a component only says
|
|
* whether it is still watching.
|
|
*/
|
|
let socket: WebSocket | null = null
|
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
let retry = RECONNECT_MIN
|
|
/** How many mounted components want the socket open. */
|
|
let watchers = 0
|
|
/** Consecutive 1008s, cleared by a handshake the server accepts. */
|
|
let rejected = 0
|
|
/** A ticket is being fetched, so a socket is on its way. */
|
|
let connecting = false
|
|
let client: QueryClient | null = null
|
|
const authHandlers = new Set<() => void>()
|
|
/** Floor re-reads still owed, per node, so a recovery can cancel its own. */
|
|
const healthTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
|
|
/** Everything a node's health shows up in, which is two unrelated keys. */
|
|
function invalidateHealth(flow: string | undefined) {
|
|
// `graph` is a sibling of the flow's own key rather than a child, so the
|
|
// detail invalidation alone never reaches Home's fault rings.
|
|
for (const queryKey of [
|
|
flow ? flowKeys.detail(flow) : flowKeys.all,
|
|
flowKeys.graph,
|
|
]) {
|
|
client?.invalidateQueries({ queryKey })
|
|
}
|
|
}
|
|
|
|
function schedule() {
|
|
if (timer) return
|
|
// Jittered, because every client of an engine that restarted is counting
|
|
// the same backoff from the same moment: without it they all come back
|
|
// together, and keep coming back together.
|
|
timer = setTimeout(
|
|
() => {
|
|
timer = null
|
|
connect()
|
|
},
|
|
retry * (0.5 + Math.random()),
|
|
)
|
|
retry = Math.min(retry * 2, RECONNECT_MAX)
|
|
}
|
|
|
|
/**
|
|
* Say which messages this page wants frames for.
|
|
*
|
|
* Bytes are the one thing the socket does not send unasked: a camera is
|
|
* hundreds of kilobytes a second and most tabs are drawing no media at all. So
|
|
* a Media tile or a thumbnail registers its message, and this tells the engine
|
|
* — again on every reconnect, since the new socket knows nothing.
|
|
*/
|
|
function sendWanted() {
|
|
if (socket?.readyState !== WebSocket.OPEN) return
|
|
socket.send(JSON.stringify({ type: "media", names: liveStore.wantedNames() }))
|
|
}
|
|
|
|
liveStore.onWanted(sendWanted)
|
|
|
|
/** The token was refused, by the socket or by the ticket call before it. */
|
|
function refused(): boolean {
|
|
rejected += 1
|
|
if (rejected < AUTH_ATTEMPTS) return false
|
|
for (const handler of authHandlers) handler()
|
|
return true
|
|
}
|
|
|
|
function connect() {
|
|
if (watchers === 0 || socket || timer || connecting) return
|
|
connecting = true
|
|
socketUrl().then(
|
|
(url) => {
|
|
connecting = false
|
|
open(url)
|
|
},
|
|
(error: unknown) => {
|
|
connecting = false
|
|
if (watchers === 0) return
|
|
// A 401 is the 1008 of the ticket call; anything else is the engine
|
|
// not answering yet, which the backoff is for.
|
|
if (error instanceof ApiError && error.status === 401 && refused()) return
|
|
schedule()
|
|
},
|
|
)
|
|
}
|
|
|
|
function open(url: string) {
|
|
if (watchers === 0 || socket || timer) return
|
|
const ws = new WebSocket(url)
|
|
ws.binaryType = "arraybuffer"
|
|
socket = ws
|
|
|
|
ws.onopen = () => {
|
|
retry = RECONNECT_MIN
|
|
rejected = 0
|
|
liveStore.setConnected(true)
|
|
connectionStore.setSocketOpen(true)
|
|
// Whatever happened while the socket was down was missed, so nothing
|
|
// held in cache can be trusted to still be current. Scoped to what this
|
|
// socket actually feeds: an unqualified invalidation refetches every
|
|
// query the page holds, and the usual reason the socket dropped is the
|
|
// engine restarting — so every open tab and every wall panel did that at
|
|
// once, at the moment it was least able to answer.
|
|
for (const queryKey of [
|
|
flowKeys.all,
|
|
dashboardKeys.all,
|
|
panelKeys.all,
|
|
runKeys.all,
|
|
healthKeys.all,
|
|
]) {
|
|
client?.invalidateQueries({ queryKey })
|
|
}
|
|
sendWanted()
|
|
}
|
|
|
|
const handle = (message: FlowEvent) => {
|
|
switch (message.type) {
|
|
case "snapshot":
|
|
liveStore.setValues(message.values)
|
|
liveStore.setStatuses(message.nodes ?? [])
|
|
liveStore.setPausedFlows(message.paused ?? [])
|
|
liveStore.setLogs(message.logs ?? [])
|
|
// Missing against an instance older than this bundle; the graph
|
|
// then starts from zero the way it always did.
|
|
liveStore.setEmits(message.emits ?? {})
|
|
// `node_queued` is announced once per wait, so a socket that opens
|
|
// mid-wait learns of it here or draws the node idle.
|
|
for (const entry of message.queued ?? []) {
|
|
liveStore.setStatus(entry.node, {
|
|
status: "queued",
|
|
detail: entry.reason,
|
|
})
|
|
}
|
|
break
|
|
case "message_value":
|
|
liveStore.setValue(message.name, {
|
|
value: message.value,
|
|
ts: message.ts,
|
|
source: message.source,
|
|
})
|
|
break
|
|
case "node_queued":
|
|
// Waiting for a machine, which looks exactly like hung from outside.
|
|
// `node_started` overwrites this, so nothing has to clear it.
|
|
liveStore.setStatus(message.node, {
|
|
status: "queued",
|
|
detail: message.detail,
|
|
})
|
|
break
|
|
case "node_started":
|
|
liveStore.setStatus(message.node, { status: "running" })
|
|
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,
|
|
})
|
|
liveStore.recordEngineEvent({
|
|
type: message.type,
|
|
flow: message.flow,
|
|
node: message.node,
|
|
detail: message.error,
|
|
ts: message.ts ?? Date.now() / 1000,
|
|
})
|
|
break
|
|
case "node_health": {
|
|
// The canvas draws node health from the flow detail's `issues`, which
|
|
// the server derives — so the screen only moved on mount, navigation
|
|
// or a rebuild, never when health actually flipped. The store had a
|
|
// health map of its own and nothing ever read it.
|
|
invalidateHealth(message.flow)
|
|
if (message.health === "down") {
|
|
// This fires at the transition, while the floor is still hiding the
|
|
// node, and the engine publishes nothing when the floor expires — so
|
|
// read again ourselves at that moment, or the issue waits for an
|
|
// unrelated invalidation. Only the first `down` schedules it: the
|
|
// engine restarts its own clock on a transition, not on a connector
|
|
// rotating its error string while staying down.
|
|
if (!healthTimers.has(message.node)) {
|
|
healthTimers.set(
|
|
message.node,
|
|
setTimeout(() => {
|
|
healthTimers.delete(message.node)
|
|
invalidateHealth(message.flow)
|
|
}, HEALTH_FLOOR),
|
|
)
|
|
}
|
|
liveStore.recordEngineEvent({
|
|
type: message.type,
|
|
flow: message.flow,
|
|
node: message.node,
|
|
detail: message.detail ?? "Reported itself down.",
|
|
ts: message.ts ?? Date.now() / 1000,
|
|
})
|
|
} else {
|
|
// Recovery clears at once server-side, so nothing is owed.
|
|
const owed = healthTimers.get(message.node)
|
|
if (owed) clearTimeout(owed)
|
|
healthTimers.delete(message.node)
|
|
}
|
|
break
|
|
}
|
|
case "flow_quarantined":
|
|
case "engine_degraded":
|
|
case "engine_fatal":
|
|
case "cascade_dropped":
|
|
case "queue_unavailable":
|
|
liveStore.recordEngineEvent({
|
|
type: message.type,
|
|
flow: "flow" in message ? message.flow : undefined,
|
|
node: "node" in message ? message.node : undefined,
|
|
detail:
|
|
("error" in message ? message.error : undefined) ??
|
|
("reason" in message ? message.reason : undefined) ??
|
|
"",
|
|
ts: message.ts ?? Date.now() / 1000,
|
|
})
|
|
break
|
|
case "node_status":
|
|
liveStore.setStatus(message.node, {
|
|
status: message.status as "active" | "error",
|
|
error: message.error,
|
|
})
|
|
break
|
|
case "node_error_acknowledged":
|
|
// Someone dismissed it, here or in another browser. The engine has
|
|
// already forgotten it, so drop the marker rather than posting back.
|
|
liveStore.clearFailure(message.node)
|
|
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.
|
|
client?.invalidateQueries({ queryKey: flowKeys.all })
|
|
break
|
|
case "dashboard_changed":
|
|
// Someone published, or changed which dashboards a panel shows. Refetch
|
|
// rather than reload: a wall screen must not blank or sign in again.
|
|
// `exact` matters — the list key is a prefix of every detail key,
|
|
// including the draft an editor may have open.
|
|
client?.invalidateQueries({ queryKey: dashboardKeys.all, exact: true })
|
|
client?.invalidateQueries({
|
|
queryKey: message.dashboard
|
|
? dashboardKeys.detail(message.dashboard)
|
|
: panelKeys.all,
|
|
})
|
|
break
|
|
case "run_started":
|
|
case "run_finished":
|
|
// One invalidation covers the lot: the list, the flow counts, the run
|
|
// being watched, and any chart drawing a run's curve. A run is not a
|
|
// live value, so nothing here goes through the live store.
|
|
client?.invalidateQueries({ queryKey: runKeys.all })
|
|
break
|
|
case "run_metric":
|
|
// A batch of readings was written. Only what draws them is refetched —
|
|
// the run's own detail (its metric names and curve hang below that
|
|
// key) and any comparison — so a training run reporting every second
|
|
// does not re-read the list behind it.
|
|
client?.invalidateQueries({ queryKey: runKeys.detail(message.run) })
|
|
client?.invalidateQueries({ queryKey: runKeys.compares })
|
|
break
|
|
}
|
|
}
|
|
|
|
ws.onmessage = (event) => {
|
|
// A frame that is not JSON, or one this bundle cannot read, costs the
|
|
// frame rather than the connection: an exception thrown here escapes into
|
|
// `window.onerror` and leaves whatever it had already applied behind.
|
|
try {
|
|
if (event.data instanceof ArrayBuffer) {
|
|
// Media, sent in front of the value that names it — so the tile has
|
|
// the bytes by the time it hears the message changed.
|
|
const frame = parseMediaFrame(event.data)
|
|
if (frame) liveStore.setBytes(frame.header.digest, frame.bytes)
|
|
return
|
|
}
|
|
const payload = JSON.parse(event.data)
|
|
if (payload?.type === "batch") {
|
|
// A cascade publishes a dozen events at once and the engine coalesces
|
|
// them into one frame. An instance older than this bundle sends
|
|
// them one at a time, which is the branch below.
|
|
for (const message of payload.events ?? []) handle(message)
|
|
} else {
|
|
handle(payload)
|
|
}
|
|
} catch (error) {
|
|
console.warn("Dropped an unreadable socket frame", error)
|
|
}
|
|
}
|
|
|
|
ws.onclose = (event) => {
|
|
// A socket we already dropped: its close says nothing about the connection
|
|
// we want now.
|
|
if (socket !== ws) return
|
|
socket = null
|
|
liveStore.setConnected(false)
|
|
connectionStore.setSocketOpen(false)
|
|
if (watchers === 0) return
|
|
if (event.code === 1008 && refused()) return
|
|
// 1013 is the portal saying the instance is not attached — the one
|
|
// close code that means "offline" rather than "the socket dropped".
|
|
if (event.code === 1013) {
|
|
connectionStore.setOffline(null)
|
|
}
|
|
schedule()
|
|
}
|
|
}
|
|
|
|
function release(onAuthFailure?: () => void) {
|
|
watchers -= 1
|
|
if (onAuthFailure) authHandlers.delete(onAuthFailure)
|
|
if (watchers > 0) return
|
|
if (timer) {
|
|
clearTimeout(timer)
|
|
timer = null
|
|
}
|
|
for (const owed of healthTimers.values()) clearTimeout(owed)
|
|
healthTimers.clear()
|
|
const ws = socket
|
|
socket = null
|
|
ws?.close()
|
|
liveStore.setConnected(false)
|
|
// Its `onclose` is dropped by the guard above, so say so here instead.
|
|
connectionStore.setSocketOpen(false)
|
|
}
|
|
|
|
/**
|
|
* Keeps one socket open while anything on the page wants live events.
|
|
*
|
|
* @param onAuthFailure called once the server has refused the token often
|
|
* enough to mean the session is gone, so the caller can send the user back to
|
|
* the login screen.
|
|
*/
|
|
export function useFlowSocket(onAuthFailure?: () => void): void {
|
|
const queryClient = useQueryClient()
|
|
|
|
useEffect(() => {
|
|
client = queryClient
|
|
if (onAuthFailure) authHandlers.add(onAuthFailure)
|
|
watchers += 1
|
|
connect()
|
|
return () => release(onAuthFailure)
|
|
}, [onAuthFailure, queryClient])
|
|
}
|