Health: a flow that cannot run says so, and the brain marks which neurons
A dependency loop is flagged on the canvas and was invisible everywhere else: /observability/summary answered "ok" with an empty problems list while the published flow could not run at all. It now reports the flows validation blocks, and the brain graph carries the reason on each neuron the issue names so the view built to find broken wiring can show it. Node errors stay counted once, as the nodes that failed to load, and an advisory like an unauthenticated webhook marks nothing — it is worth saying, but the flow still runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XC2jX6Hdj7pxGGKzBTrbqB
This commit is contained in:
@@ -17,7 +17,7 @@ from sqlalchemy import func
|
||||
from sqlmodel import col, select
|
||||
|
||||
from app.api.deps import FlowControllerDep, SessionDep, get_current_user
|
||||
from app.flow.controller import NodeStatus
|
||||
from app.flow.controller import ADVISORY_ISSUES, NodeStatus
|
||||
from app.models import EngineEvent, FlowRun, MetricBucket
|
||||
|
||||
router = APIRouter(
|
||||
@@ -128,6 +128,22 @@ async def read_summary(
|
||||
if errored:
|
||||
problems.append(f"{len(errored)} node(s) failed to load")
|
||||
|
||||
# What the canvas flags on a flow — a dependency loop, an input nothing
|
||||
# feeds — stops that flow running just as surely as a node that will not
|
||||
# load, and until now this screen was the one place it did not show.
|
||||
# `node_error` is left out: those are the nodes already counted above.
|
||||
invalid = sorted(
|
||||
{
|
||||
issue.flow
|
||||
for issue in controller.issues
|
||||
if issue.flow
|
||||
and issue.code != "node_error"
|
||||
and issue.code not in ADVISORY_ISSUES
|
||||
}
|
||||
)
|
||||
if invalid:
|
||||
problems.append(f"{len(invalid)} flow(s) cannot run: {', '.join(invalid)}")
|
||||
|
||||
statement = (
|
||||
select(func.count())
|
||||
.select_from(EngineEvent)
|
||||
@@ -146,6 +162,7 @@ async def read_summary(
|
||||
),
|
||||
"paused": len(paused),
|
||||
"quarantined": len(quarantined),
|
||||
"invalid": len(invalid),
|
||||
},
|
||||
nodes={"total": len(entries), "error": len(errored)},
|
||||
queue=queue,
|
||||
|
||||
@@ -62,6 +62,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
HOOK_PREFIX = "/hooks"
|
||||
|
||||
# Validation codes that are worth saying but do not stop a flow running, so
|
||||
# neither the brain graph nor the health summary treats them as a fault.
|
||||
ADVISORY_ISSUES = frozenset({"unauthenticated_hook"})
|
||||
|
||||
|
||||
class NodeStatus(str, Enum):
|
||||
ACTIVE = "active"
|
||||
@@ -701,6 +705,15 @@ class FlowController:
|
||||
compiled pipeline, so a node that failed to load still appears — a
|
||||
broken neuron is exactly what someone comes to this view to find.
|
||||
"""
|
||||
# What validation found, by the node it names. A cycle names every node
|
||||
# in it, so all of them are marked rather than an arbitrary one.
|
||||
troubled: dict[str, str] = {}
|
||||
for issue in self.issues:
|
||||
if issue.code in ADVISORY_ISSUES:
|
||||
continue
|
||||
for member in (*issue.nodes, *filter(None, [issue.node])):
|
||||
troubled.setdefault(member, issue.message)
|
||||
|
||||
groups: dict[str, BrainNode] = {}
|
||||
# Which group each `flow.node_id` ended up in.
|
||||
gid_of: dict[str, str] = {}
|
||||
@@ -738,6 +751,10 @@ class FlowController:
|
||||
group.members.append(member)
|
||||
if flow.name not in group.flows:
|
||||
group.flows.append(flow.name)
|
||||
# A merged neuron stands for several nodes, so one of them being
|
||||
# unable to run is enough to mark it; the first reason wins.
|
||||
if group.issue is None:
|
||||
group.issue = troubled.get(member)
|
||||
|
||||
for spec in _bound(node_def.provides):
|
||||
producers.setdefault(qualify(flow.name, spec.name), []).append(
|
||||
|
||||
@@ -200,6 +200,15 @@ class BrainNode(BaseModel):
|
||||
kind: str
|
||||
members: list[str] = Field(default_factory=list)
|
||||
flows: list[str] = Field(default_factory=list)
|
||||
issue: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Why this neuron cannot run, if validation found something. A "
|
||||
"failure the engine hits while running arrives over the socket "
|
||||
"instead; this is the part that is already true before anything "
|
||||
"fires, and so has to travel with the graph."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class BrainEdge(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user