fix(python): surface Python tracebacks in node error + dialog Output

Backend: PythonTransformNode wraps script execution in a try/catch that
runs the exception through IronPython's ExceptionOperations.FormatException.
Users now see a proper Python-style traceback (file, line, type, message)
instead of the bare CLR message (which was often empty or just "unexpected
token"). Source is named "script" for readable File lines in the trace.

Frontend: findLatestIo also returns status + error; NodeEditorDialog
renders a red-bordered traceback block in the Output column when the
node failed, replacing the misleading "No data yet" state.
This commit is contained in:
Claude
2026-08-31 10:47:34 +00:00
parent 9c164282a1
commit 3fa541a032
6 changed files with 104 additions and 7 deletions

View File

@@ -179,6 +179,31 @@ describe("NodeEditorDialog", () => {
await waitFor(() => expect(screen.getByText("code")).toBeInTheDocument());
});
it("shows the node error (Python traceback) in the Output column on failure", async () => {
server.use(
http.get("/api/workflows/:id/executions", () =>
HttpResponse.json([
{ id: "e-fail", workflowId: "wf1", trigger: "manual", status: "failed",
startedAt: "", endedAt: null, error: null } satisfies ExecutionSummary,
])),
http.get("/api/executions/e-fail", () =>
HttpResponse.json({
id: "e-fail", workflowId: "wf1", trigger: "manual", status: "failed",
startedAt: "", endedAt: "", error: null,
nodes: [
{ nodeId: "n1", status: "failed", startedAt: "", endedAt: "",
error: "Traceback (most recent call last):\n File \"script\", line 1, in <module>\nNameError: name 'x' is not defined",
inputs: [], outputs: [] },
],
} satisfies ExecutionDetail)),
);
renderDialog();
const box = await screen.findByTestId("node-error");
expect(box.textContent).toContain("Traceback");
expect(box.textContent).toContain("NameError");
expect(box.textContent).toContain("name 'x' is not defined");
});
it("Webhook URL block shows for http.webhook", () => {
const node: Node<VcNodeData> = {
id: "w1", type: "vc",

View File

@@ -89,6 +89,7 @@ export function NodeEditorDialog({
const inputsArray = normalizePorts(io?.inputs);
const outputsArray = normalizePorts(io?.outputs);
const failure = io?.status === "failed" ? (io.error ?? "(no error message)") : null;
const previewInput = inputsArray[0]?.items[0]?.json;
const invalidate = useInvalidate();
@@ -247,7 +248,7 @@ export function NodeEditorDialog({
</button>
</div>
</div>
<PortsColumn title="Output" ports={outputsArray} clickableFirst={false} />
<PortsColumn title="Output" ports={outputsArray} clickableFirst={false} failure={failure} />
</div>
</div>
</div>
@@ -265,17 +266,28 @@ function normalizePorts(v: unknown): Port[] {
}
function PortsColumn({
title, ports, clickableFirst, onPickPath,
title, ports, clickableFirst, onPickPath, failure,
}: {
title: "Input" | "Output";
ports: Port[];
clickableFirst: boolean;
onPickPath?: (path: string) => void;
// When set, the node failed — render the error text (typically a Python
// traceback) instead of "No data yet". Only meaningful for the Output column.
failure?: string | null;
}) {
return (
<div className="p-3 overflow-y-auto">
<h3 className="text-xs uppercase tracking-wide text-slate-500 mb-2">{title}</h3>
{ports.length === 0 && (
{failure && (
<div
data-testid="node-error"
className="mb-3 rounded border border-red-900 bg-red-950/40 p-2 text-xs font-mono text-red-300 whitespace-pre-wrap break-words"
>
{failure}
</div>
)}
{!failure && ports.length === 0 && (
<div className="text-xs text-slate-500 italic">
No data yet — press <span className="text-slate-300">Run</span> or activate the workflow.
</div>

View File

@@ -22,9 +22,27 @@ describe("findLatestIo", () => {
expect(io).toEqual({
inputs: [[{ json: { c: 3 } }]],
outputs: [[{ json: { d: 4 } }]],
status: "completed",
error: null,
});
});
it("propagates status and error for failed rows", () => {
const d: ExecutionDetail = {
id: "e1", workflowId: "wf", trigger: "manual", status: "failed",
startedAt: "", endedAt: null, error: null,
nodes: [{
nodeId: "n1", status: "failed",
startedAt: "", endedAt: "",
error: "Traceback (most recent call last):\n File \"script\", line 1, in <module>\nNameError: name 'x' is not defined",
inputs: [], outputs: [],
}],
};
const io = findLatestIo(d, "n1")!;
expect(io.status).toBe("failed");
expect(io.error).toContain("NameError");
});
it("returns null when the node did not appear in the execution", () => {
const d = detail([{ nodeId: "n1", inputs: [], outputs: [] }]);
expect(findLatestIo(d, "missing")).toBeNull();

View File

@@ -1,13 +1,15 @@
import type { ExecutionDetail } from "@/api/types";
import type { ExecutionDetail, NodeExecutionDto } from "@/api/types";
export interface NodeIo {
inputs: unknown;
outputs: unknown;
status: NodeExecutionDto["status"];
error: string | null;
}
export function findLatestIo(execution: ExecutionDetail | undefined, nodeId: string): NodeIo | null {
if (!execution) return null;
const row = execution.nodes.find((n) => n.nodeId === nodeId);
if (!row) return null;
return { inputs: row.inputs, outputs: row.outputs };
return { inputs: row.inputs, outputs: row.outputs, status: row.status, error: row.error };
}

View File

@@ -41,8 +41,23 @@ public sealed class PythonTransformNode : IActionNode
foreach (var i in inputsByPort[0]) pyItems.Add(JsonToPython.ToPython(i.Json));
scope.SetVariable("items", pyItems);
var source = _engine.CreateScriptSourceFromString(script, Microsoft.Scripting.SourceCodeKind.Statements);
source.Execute(scope);
// Name the source so tracebacks show `File "script"` instead of `<string>`.
var source = _engine.CreateScriptSourceFromString(script, "script", Microsoft.Scripting.SourceCodeKind.Statements);
try
{
source.Execute(scope);
}
catch (Exception ex)
{
// IronPython's ExceptionOperations turns both SyntaxErrors and runtime
// exceptions into a Python-style traceback. Without this, the executor
// stores the bare CLR message ("unexpected token", "name 'x' is not
// defined") with no line number or source line — unhelpful to users.
var eo = _engine.GetService<Microsoft.Scripting.Hosting.ExceptionOperations>();
var formatted = eo?.FormatException(ex).TrimEnd();
throw new InvalidOperationException(
string.IsNullOrEmpty(formatted) ? ex.Message : formatted);
}
if (!scope.TryGetVariable("output", out object? outputObj))
return Task.FromResult<IReadOnlyList<IReadOnlyList<Item>>>(new[] { Array.Empty<Item>() });

View File

@@ -58,4 +58,29 @@ public class PythonTransformNodeTests
await Assert.ThrowsAnyAsync<Exception>(() =>
node.ExecuteAsync(Ctx("raise ValueError('bad')"), input, CancellationToken.None));
}
[Fact]
public async Task Runtime_error_message_contains_python_traceback_with_line_and_type()
{
var node = new PythonTransformNode();
var input = new IReadOnlyList<Item>[] { new [] { new Item(JsonDocument.Parse("{}").RootElement) } };
var script = "x = 1\noutput = [notdefined + 1]";
var ex = await Assert.ThrowsAnyAsync<Exception>(() =>
node.ExecuteAsync(Ctx(script), input, CancellationToken.None));
Assert.Contains("Traceback", ex.Message);
Assert.Contains("NameError", ex.Message);
Assert.Contains("notdefined", ex.Message);
Assert.Contains("line 2", ex.Message);
}
[Fact]
public async Task Syntax_error_message_contains_python_traceback()
{
var node = new PythonTransformNode();
var input = new IReadOnlyList<Item>[] { new [] { new Item(JsonDocument.Parse("{}").RootElement) } };
var script = "output = [";
var ex = await Assert.ThrowsAnyAsync<Exception>(() =>
node.ExecuteAsync(Ctx(script), input, CancellationToken.None));
Assert.Contains("SyntaxError", ex.Message);
}
}