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:
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>() });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user