Refuse a port the function cannot take, and read a failing node as degraded
Docs / docs (push) Successful in 33s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m26s
Playwright Tests / test-playwright (2, 2) (push) Failing after 15s
pre-commit / pre-commit (push) Failing after 1m43s
Test Backend / test-backend (push) Failing after 2m46s
Compose Smoke Test / test-compose (push) Failing after 12s
Playwright Tests / merge-reports (push) Canceled after 0s

A python node's ports and settings arrive as keyword arguments, so a declared
name its `process` does not take was a TypeError on every call — and a node
that loads fine and fails every time it runs is the quiet kind of broken: the
hosted demo did it 720 times an hour for two days and the health badge read
ok throughout. `_build_node` now reads a written body with `ast` and refuses
the mismatch at load, so the node is an error on the canvas and an issue on
publish. Skipped for `**kwargs`, a decorated or absent `process`, and the
template a new node opens with. The SDK's generated shim always takes
`**settings`, so synced flows are untouched.

`/observability/summary` names a node that has failed in the last fifteen
minutes and reads degraded while it does, which is what would have made the
badge amber. `nodes.failing` carries the count.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SkgNtaR6JspnHBFFP6crZj
This commit is contained in:
2026-09-02 22:37:44 +02:00
co-authored by Claude Fable 5.1
parent 65272a135f
commit 3397739c14
5 changed files with 191 additions and 2 deletions
+50
View File
@@ -14,6 +14,7 @@ nodes are built like any other's, so `set_enabled` only starts or stops them.
from __future__ import annotations
import ast
import asyncio
import hashlib
import json
@@ -1027,6 +1028,19 @@ class FlowController:
flow, node_def.id
)
# Only a body somebody wrote: the template a new node opens
# with takes nothing, and a node declared before it is written
# is reported as missing its source rather than as wrong.
written = bool(node_def.source_ref) or self.store.has_node_source(
flow, node_def.id, draft=draft
)
if written:
gap = _signature_gap(
code, node_def.id, ports, set(params) - RESERVED_SETTINGS
)
if gap:
raise ValueError(gap)
# What a node produces before it returns comes back as frames;
# this puts them through the node's own ports.
emissions = EmitSink()
@@ -1716,6 +1730,42 @@ def _bound(specs: list[MessageSpec]) -> list[MessageSpec]:
return [spec for spec in specs if spec.name]
def _signature_gap(
code: str, node_id: str, ports: set[str], settings: set[str]
) -> str | None:
"""A declared port or setting ``process`` cannot accept, or None.
Ports and settings both arrive as keyword arguments, so a name the function
does not take is a ``TypeError`` on every call — and a node that loads fine
and fails every time it runs is the quiet kind of broken. Read statically:
with a worker pool the code is never imported here. Anything this cannot
read for certain (no plain ``def process``, a decorator, ``**kwargs``, a
syntax error the compile step words better) is left to the call.
"""
try:
tree = ast.parse(code)
except SyntaxError:
return None
defs = [
node
for node in tree.body
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef)
and node.name == "process"
]
if not defs or defs[-1].decorator_list or defs[-1].args.kwarg is not None:
return None
args = defs[-1].args
names = [a.arg for a in [*args.posonlyargs, *args.args, *args.kwonlyargs]]
missing = sorted((ports | settings) - set(names))
if not missing:
return None
kind = "an input" if missing[0] in ports else "a setting"
return (
f"'{missing[0]}' is {kind} of '{node_id}' but "
f"process({', '.join(names)}) takes no such argument"
)
def with_settings(
function: Callable[..., Any], params: dict[str, Any]
) -> Callable[..., Any]: