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()); 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", () => { it("Webhook URL block shows for http.webhook", () => {
const node: Node<VcNodeData> = { const node: Node<VcNodeData> = {
id: "w1", type: "vc", id: "w1", type: "vc",

View File

@@ -89,6 +89,7 @@ export function NodeEditorDialog({
const inputsArray = normalizePorts(io?.inputs); const inputsArray = normalizePorts(io?.inputs);
const outputsArray = normalizePorts(io?.outputs); const outputsArray = normalizePorts(io?.outputs);
const failure = io?.status === "failed" ? (io.error ?? "(no error message)") : null;
const previewInput = inputsArray[0]?.items[0]?.json; const previewInput = inputsArray[0]?.items[0]?.json;
const invalidate = useInvalidate(); const invalidate = useInvalidate();
@@ -247,7 +248,7 @@ export function NodeEditorDialog({
</button> </button>
</div> </div>
</div> </div>
<PortsColumn title="Output" ports={outputsArray} clickableFirst={false} /> <PortsColumn title="Output" ports={outputsArray} clickableFirst={false} failure={failure} />
</div> </div>
</div> </div>
</div> </div>
@@ -265,17 +266,28 @@ function normalizePorts(v: unknown): Port[] {
} }
function PortsColumn({ function PortsColumn({
title, ports, clickableFirst, onPickPath, title, ports, clickableFirst, onPickPath, failure,
}: { }: {
title: "Input" | "Output"; title: "Input" | "Output";
ports: Port[]; ports: Port[];
clickableFirst: boolean; clickableFirst: boolean;
onPickPath?: (path: string) => void; 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 ( return (
<div className="p-3 overflow-y-auto"> <div className="p-3 overflow-y-auto">
<h3 className="text-xs uppercase tracking-wide text-slate-500 mb-2">{title}</h3> <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"> <div className="text-xs text-slate-500 italic">
No data yet — press <span className="text-slate-300">Run</span> or activate the workflow. No data yet — press <span className="text-slate-300">Run</span> or activate the workflow.
</div> </div>

View File

@@ -22,9 +22,27 @@ describe("findLatestIo", () => {
expect(io).toEqual({ expect(io).toEqual({
inputs: [[{ json: { c: 3 } }]], inputs: [[{ json: { c: 3 } }]],
outputs: [[{ json: { d: 4 } }]], 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", () => { it("returns null when the node did not appear in the execution", () => {
const d = detail([{ nodeId: "n1", inputs: [], outputs: [] }]); const d = detail([{ nodeId: "n1", inputs: [], outputs: [] }]);
expect(findLatestIo(d, "missing")).toBeNull(); 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 { export interface NodeIo {
inputs: unknown; inputs: unknown;
outputs: unknown; outputs: unknown;
status: NodeExecutionDto["status"];
error: string | null;
} }
export function findLatestIo(execution: ExecutionDetail | undefined, nodeId: string): NodeIo | null { export function findLatestIo(execution: ExecutionDetail | undefined, nodeId: string): NodeIo | null {
if (!execution) return null; if (!execution) return null;
const row = execution.nodes.find((n) => n.nodeId === nodeId); const row = execution.nodes.find((n) => n.nodeId === nodeId);
if (!row) return null; 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)); foreach (var i in inputsByPort[0]) pyItems.Add(JsonToPython.ToPython(i.Json));
scope.SetVariable("items", pyItems); scope.SetVariable("items", pyItems);
var source = _engine.CreateScriptSourceFromString(script, Microsoft.Scripting.SourceCodeKind.Statements); // Name the source so tracebacks show `File "script"` instead of `<string>`.
source.Execute(scope); 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)) if (!scope.TryGetVariable("output", out object? outputObj))
return Task.FromResult<IReadOnlyList<IReadOnlyList<Item>>>(new[] { Array.Empty<Item>() }); return Task.FromResult<IReadOnlyList<IReadOnlyList<Item>>>(new[] { Array.Empty<Item>() });

View File

@@ -58,4 +58,29 @@ public class PythonTransformNodeTests
await Assert.ThrowsAnyAsync<Exception>(() => await Assert.ThrowsAnyAsync<Exception>(() =>
node.ExecuteAsync(Ctx("raise ValueError('bad')"), input, CancellationToken.None)); 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);
}
} }