Let a node's failure outlive the run that followed it
A node's error cleared the moment it ran again, so a failure that genuinely fired an alert could leave no trace on the canvas by the time anyone looked. The engine records it now — on the node's status, so it survives a reload and every client agrees — and reading the traceback is what clears it. The seam is the event bus, which is where every failing path already meets: a queued live run, an explicit run, a preview, and a single triggered node all publish `node_error`, while the controller's own observer would have seen only one of them. That was half the confusion. The other half: clicking a failed neuron on Home often landed on a flow where everything looked fine. Nodes merge into one neuron by instance key — every InfluxDB node pointing at the same bucket is one neuron — and the click went to whichever flow contributed a member first, not the one that failed. It now goes to the failing member and selects it, and the canvas marks a failing node rather than leaving it to the dot alone. The inject node emitted one payload to every port it declared, whatever their types, so an inject on a bool port carrying the text "true" raised at publish time. Each port gets its own field now, typed and parsed by that port's dtype, and remembers what it last sent. A port that is renamed carries its value with it; one that is removed takes its value with it. An inject written before this keeps emitting exactly what it did. The derived-cron chip also appeared on the delay node, where `interval` is a rate limit and a schedule derived from it means nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import { useCallback, useSyncExternalStore } from "react"
|
||||
|
||||
import { OpenAPI } from "@/client"
|
||||
import { request } from "@/client/core/request"
|
||||
|
||||
/**
|
||||
* Live engine state, deliberately outside React Query.
|
||||
*
|
||||
@@ -29,6 +32,16 @@ export type NodeHealth = {
|
||||
health: "ok" | "down" | "unknown"
|
||||
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
|
||||
@@ -56,6 +69,7 @@ const ENGINE_EVENT_LIMIT = 100
|
||||
|
||||
const values = new Map<string, LiveValue>()
|
||||
const statuses = new Map<string, LiveStatus>()
|
||||
const failures = new Map<string, NodeFailure>()
|
||||
const health = new Map<string, NodeHealth>()
|
||||
let engineEvents: EngineEvent[] = []
|
||||
// How many times this page has seen a node emit. The number itself means
|
||||
@@ -107,9 +121,26 @@ export const liveStore = {
|
||||
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 }[],
|
||||
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, {
|
||||
@@ -117,11 +148,46 @@ export const liveStore = {
|
||||
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)
|
||||
},
|
||||
/**
|
||||
* 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(() => {})
|
||||
},
|
||||
setHealth(nodeId: string, entry: NodeHealth) {
|
||||
health.set(nodeId, entry)
|
||||
notify(`health:${nodeId}`)
|
||||
@@ -193,6 +259,8 @@ export const liveStore = {
|
||||
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()
|
||||
@@ -222,6 +290,14 @@ export function useNodeStatus(nodeId: string): LiveStatus | undefined {
|
||||
)
|
||||
}
|
||||
|
||||
/** 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),
|
||||
)
|
||||
}
|
||||
|
||||
/** How the node's connection is doing, once it has said anything about it. */
|
||||
export function useNodeHealth(nodeId: string): NodeHealth | undefined {
|
||||
return useSyncExternalStore(
|
||||
@@ -324,19 +400,29 @@ export function useGroupActive(ids: string[]): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether any of these nodes is currently failing. */
|
||||
export function useGroupError(ids: string[]): boolean {
|
||||
/**
|
||||
* 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) => `status:${id}`),
|
||||
parts(joined).map((id) => `failure:${id}`),
|
||||
listener,
|
||||
),
|
||||
[joined],
|
||||
),
|
||||
() => parts(joined).some((id) => statuses.get(id)?.status === "error"),
|
||||
() =>
|
||||
parts(joined)
|
||||
.map((id) => failures.get(id)?.error ?? "")
|
||||
.find(Boolean) ?? "",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user