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:
2026-08-21 14:33:41 +02:00
co-authored by Claude Opus 5
parent 06f84e18ae
commit b0efb4b0f1
19 changed files with 738 additions and 86 deletions
+69 -32
View File
@@ -18,6 +18,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { qualify } from "./deriveEdges"
import { useLiveValue } from "./liveStore"
import { asText, DTYPES } from "./NodePanel"
@@ -46,6 +47,66 @@ export function parseByDtype(dtype: DType | undefined, raw: string): unknown {
}
}
/**
* One literal, entered the way its type is entered.
*
* A flag has two values and gets a choice of them; everything else is typed
* and read back with {@link parseByDtype}. Deliberately not `type="number"`
* for the numbers — see that function on why half-typed input must survive.
*
* `className` carries no height: the two controls need different ones.
*/
export function DtypeValue({
dtype,
value,
label,
id,
placeholder,
className,
onChange,
}: {
dtype: DType | undefined
value: unknown
/** What the field is called, for anyone not looking at it. */
label: string
id?: string
placeholder?: string
className?: string
onChange: (next: unknown) => void
}) {
if (dtype === "bool") {
return (
<Select
value={value === true ? "true" : "false"}
onValueChange={(next) => onChange(next === "true")}
>
<SelectTrigger
id={id}
className={cn("!h-8", className)}
aria-label={label}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">true</SelectItem>
<SelectItem value="false">false</SelectItem>
</SelectContent>
</Select>
)
}
return (
<Input
id={id}
value={asText(value)}
placeholder={placeholder}
aria-label={label}
className={cn("h-8", className)}
onChange={(event) => onChange(parseByDtype(dtype, event.target.value))}
/>
)
}
/** Putting a declared value into the running graph, credited to the input. */
function usePublishInput() {
return useMutation({
@@ -116,38 +177,14 @@ function InputRow({
))}
</SelectContent>
</Select>
{spec.dtype === "bool" ? (
<Select
value={declared.initial === true ? "true" : "false"}
onValueChange={(next) =>
onChange({ ...declared, initial: next === "true" })
}
>
<SelectTrigger
className="!h-8 flex-1 text-sm"
aria-label="Starting value"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">true</SelectItem>
<SelectItem value="false">false</SelectItem>
</SelectContent>
</Select>
) : (
<Input
value={asText(declared.initial)}
placeholder="starts at"
aria-label="Starting value"
className="h-8 flex-1 text-sm"
onChange={(event) =>
onChange({
...declared,
initial: parseByDtype(spec.dtype, event.target.value),
})
}
/>
)}
<DtypeValue
dtype={spec.dtype}
value={declared.initial}
label="Starting value"
placeholder="starts at"
className="flex-1 text-sm"
onChange={(initial) => onChange({ ...declared, initial })}
/>
<Button
variant="ghost"
size="icon-sm"