Skip to content

Commit 2e572ed

Browse files
bobbai00claude
andcommitted
refactor(agent-service): redesign sync-execution result and error model
Restructure the per-operator summary the sync-execution backend returns and the agent-service/frontend consume, for a leaner and consistent wire contract: - Replace flat OperatorInfo with OperatorExecutionSummary: state, errorMessages, resultSummary?, consoleLogsSummary? (orthogonal sub-summaries; no shape stats). - Rename SyncExecutionResult -> WorkflowExecutionSummary; drop compilationErrors (folded into errors). errors and per-op errorMessages are non-optional (empty means none). - OperatorResultSummary.sampleTuples is now List[SampleRow] ({rowIndex, tuple}) instead of a JSON array with an embedded __row_index__. Drop the table-shape types (TableShape/InputPortTableShape): the agent derives input-port shapes from the DAG + each upstream's output shape; output shape comes from the result summary. - Reuse the engine's WorkflowFatalError for per-operator errors (the same type the compiling service returns for compilation errors), replacing the bespoke OperatorError so compile and execution errors share one wire shape. - Collapse console messages onto one type; derive warnings from WARNING-titled messages rather than a separate field. - Replace OperatorResultSummaryWs: the WS operatorResults payload now carries the canonical OperatorExecutionSummary; the frontend maps it to its flat display type (re-flattening sampleTuples to keep the display components unchanged). Touches the Scala producer (SyncExecutionResource), the agent-service consumers (result-formatting, workflow-execution-tools, workflow-result-state, server) and the frontend WS mapping. Representation/type-level change; behavior preserved, except input-port shape lines are now derived rather than explicitly rendered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qFkyrpTd5PrkNBPcBeo4Q
1 parent 2a67909 commit 2e572ed

12 files changed

Lines changed: 357 additions & 516 deletions

File tree

agent-service/src/agent/texera-agent.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -722,7 +722,7 @@ export class TexeraAgent {
722722
const result = new Map<string, string>();
723723
const visible = this.workflowResultState.getAllVisible();
724724
for (const [operatorId, entry] of visible) {
725-
result.set(operatorId, formatOperatorResult(operatorId, entry.operatorInfo, this.workflowState));
725+
result.set(operatorId, formatOperatorResult(operatorId, entry.operatorInfo));
726726
}
727727
return result;
728728
}

agent-service/src/agent/tools/result-formatting.spec.ts

Lines changed: 58 additions & 139 deletions
Original file line numberDiff line numberDiff line change
@@ -19,89 +19,89 @@
1919

2020
import { describe, expect, test } from "bun:test";
2121
import { formatOperatorResult } from "./result-formatting";
22-
import { WorkflowState } from "../workflow-state";
23-
import type { OperatorInfo } from "../../types/execution";
24-
import type { OperatorPredicate, OperatorLink, PortDescription } from "../../types/workflow";
25-
26-
function makeOpInfo(overrides: Partial<OperatorInfo> = {}): OperatorInfo {
27-
return {
28-
state: "completed",
29-
inputTuples: 0,
30-
outputTuples: 0,
31-
resultMode: "table",
32-
...overrides,
33-
};
22+
import type { OperatorExecutionSummary, OperatorState, SampleRow } from "../../types/execution";
23+
24+
// Convert flat test rows (with an optional embedded __row_index__) into the
25+
// structured SampleRow[] the summary now carries.
26+
function toSampleRows(rows: Record<string, any>[]): SampleRow[] {
27+
return rows.map((row, i) => {
28+
const { __row_index__, ...tuple } = row;
29+
return { rowIndex: typeof __row_index__ === "number" ? __row_index__ : i, tuple };
30+
});
3431
}
3532

36-
function makeOperator(id: string, inputPortIDs: string[] = []): OperatorPredicate {
37-
const inputPorts: PortDescription[] = inputPortIDs.map((portID, i) => ({
38-
portID,
39-
displayName: `Input ${i}`,
40-
}));
41-
return {
42-
operatorID: id,
43-
operatorType: "TestOp",
44-
operatorVersion: "1.0",
45-
operatorProperties: {},
46-
inputPorts,
47-
outputPorts: [{ portID: "output-0", displayName: "Output 0" }],
48-
showAdvanced: false,
49-
};
33+
// Test convenience: accept the (old) flat fields and assemble the structured
34+
// OperatorExecutionSummary, so the cases below stay terse.
35+
interface OpInfoOverrides {
36+
state?: OperatorState;
37+
error?: string;
38+
outputTuples?: number;
39+
totalRowCount?: number;
40+
warnings?: string[];
41+
result?: Record<string, any>[];
5042
}
5143

52-
function makeLink(linkID: string, source: [string, string], target: [string, string]): OperatorLink {
53-
return {
54-
linkID,
55-
source: { operatorID: source[0], portID: source[1] },
56-
target: { operatorID: target[0], portID: target[1] },
44+
function makeOpInfo(overrides: OpInfoOverrides = {}): OperatorExecutionSummary {
45+
const summary: OperatorExecutionSummary = {
46+
state: overrides.state ?? "Completed",
47+
errorMessages: overrides.error ? [{ type: "EXECUTION_FAILURE", message: overrides.error }] : [],
5748
};
49+
// The result summary is present only when the operator produced a result.
50+
if (overrides.result !== undefined) {
51+
summary.resultSummary = {
52+
resultMode: "table",
53+
// Non-arrays are passed through to exercise the "(no result data)" guard.
54+
sampleTuples: Array.isArray(overrides.result) ? toSampleRows(overrides.result) : (overrides.result as any),
55+
totalRowCount: overrides.totalRowCount ?? overrides.outputTuples ?? 0,
56+
};
57+
}
58+
if (overrides.warnings) {
59+
// Warnings are derived from console messages whose title is "WARNING: ...".
60+
summary.consoleLogsSummary = {
61+
messages: overrides.warnings.map(w => ({ msgType: "PRINT", title: w, message: "" })),
62+
};
63+
}
64+
return summary;
5865
}
5966

60-
const EMPTY_STATE = new WorkflowState();
61-
6267
describe("formatOperatorResult - early returns", () => {
6368
test("returns [ERROR] prefix when error field is set", () => {
64-
const out = formatOperatorResult("op1", makeOpInfo({ error: "boom" }), EMPTY_STATE);
69+
const out = formatOperatorResult("op1", makeOpInfo({ error: "boom" }));
6570
expect(out).toBe("[ERROR] boom");
6671
});
6772

6873
test("treats empty-string error as falsy and continues to result path", () => {
69-
const out = formatOperatorResult("op1", makeOpInfo({ error: "" }), EMPTY_STATE);
74+
const out = formatOperatorResult("op1", makeOpInfo({ error: "" }));
7075
expect(out).not.toContain("[ERROR]");
7176
expect(out).toContain("(no result data)");
7277
});
7378

7479
test("returns (no result data) when result is undefined", () => {
75-
const out = formatOperatorResult("op1", makeOpInfo(), EMPTY_STATE);
80+
const out = formatOperatorResult("op1", makeOpInfo());
7681
expect(out).toBe("(no result data)");
7782
});
7883

7984
test("returns (no result data) when result is not an array", () => {
80-
const out = formatOperatorResult(
81-
"op1",
82-
makeOpInfo({ result: { rows: [] } as unknown as Record<string, any>[] }),
83-
EMPTY_STATE
84-
);
85+
const out = formatOperatorResult("op1", makeOpInfo({ result: { rows: [] } as unknown as Record<string, any>[] }));
8586
expect(out).toBe("(no result data)");
8687
});
8788

8889
test("empty array result emits brief summary plus zero-column shape only", () => {
89-
const out = formatOperatorResult("op1", makeOpInfo({ result: [], outputTuples: 0 }), EMPTY_STATE);
90+
const out = formatOperatorResult("op1", makeOpInfo({ result: [], outputTuples: 0 }));
9091
expect(out.split("\n")).toEqual(["Executed operator op1", "Output table shape: (0, 0)"]);
9192
});
9293
});
9394

9495
describe("formatOperatorResult - table shape and metadata", () => {
9596
test("uses outputTuples for row count when totalRowCount missing", () => {
96-
const out = formatOperatorResult("op1", makeOpInfo({ outputTuples: 7, result: [{ a: 1, b: 2 }] }), EMPTY_STATE);
97+
const out = formatOperatorResult("op1", makeOpInfo({ outputTuples: 7, result: [{ a: 1, b: 2 }] }));
9798
expect(out).toContain("Output table shape: (7, 2)");
9899
});
99100

100101
test("totalRowCount overrides outputTuples in output shape", () => {
101102
const out = formatOperatorResult(
102103
"op1",
103-
makeOpInfo({ outputTuples: 7, totalRowCount: 999, result: [{ a: 1, b: 2 }] }),
104-
EMPTY_STATE
104+
makeOpInfo({ outputTuples: 7, totalRowCount: 999, result: [{ a: 1, b: 2 }] })
105105
);
106106
expect(out).toContain("Output table shape: (999, 2)");
107107
});
@@ -112,8 +112,7 @@ describe("formatOperatorResult - table shape and metadata", () => {
112112
makeOpInfo({
113113
outputTuples: 1,
114114
result: [{ __is_visualization__: true, "html-content": "<x/>" }],
115-
}),
116-
EMPTY_STATE
115+
})
117116
);
118117
// 1 visible column ("html-content") since __is_visualization__ is filtered.
119118
expect(out).toContain("Output table shape: (1, 1)");
@@ -125,85 +124,14 @@ describe("formatOperatorResult - table shape and metadata", () => {
125124
makeOpInfo({
126125
outputTuples: 1,
127126
result: [{ a: 1 }],
128-
warnings: ["truncated to 1 row", "something else"],
129-
}),
130-
EMPTY_STATE
127+
warnings: ["WARNING: truncated to 1 row", "WARNING: something else"],
128+
})
131129
);
132130
const lines = out.split("\n");
133131
expect(lines[0]).toBe("Executed operator op1");
134132
expect(lines[1]).toBe("Output table shape: (1, 1)");
135-
expect(lines[2]).toBe("truncated to 1 row");
136-
expect(lines[3]).toBe("something else");
137-
});
138-
});
139-
140-
describe("formatOperatorResult - input port metadata", () => {
141-
test("omits input metadata when inputPortShapes is missing", () => {
142-
const out = formatOperatorResult("op1", makeOpInfo({ outputTuples: 1, result: [{ a: 1 }] }), EMPTY_STATE);
143-
expect(out).not.toContain("Input operator");
144-
});
145-
146-
test("omits input metadata when inputPortShapes is empty", () => {
147-
const out = formatOperatorResult(
148-
"op1",
149-
makeOpInfo({ outputTuples: 1, result: [{ a: 1 }], inputPortShapes: [] }),
150-
EMPTY_STATE
151-
);
152-
expect(out).not.toContain("Input operator");
153-
});
154-
155-
test("falls back to inputN placeholder when no upstream link matches the port", () => {
156-
const out = formatOperatorResult(
157-
"op1",
158-
makeOpInfo({
159-
outputTuples: 1,
160-
result: [{ a: 1 }],
161-
inputPortShapes: [{ portIndex: 0, rows: 5, columns: 3 }],
162-
}),
163-
EMPTY_STATE
164-
);
165-
expect(out).toContain("Input operator(table shape): input0(5, 3)");
166-
});
167-
168-
test("uses upstream operator id when an input link matches the port", () => {
169-
const state = new WorkflowState();
170-
state.addOperator(makeOperator("upstream"));
171-
state.addOperator(makeOperator("op1", ["input-0"]));
172-
state.addLink(makeLink("l1", ["upstream", "output-0"], ["op1", "input-0"]));
173-
174-
const out = formatOperatorResult(
175-
"op1",
176-
makeOpInfo({
177-
outputTuples: 4,
178-
result: [{ a: 1, b: 2 }],
179-
inputPortShapes: [{ portIndex: 0, rows: 10, columns: 2 }],
180-
}),
181-
state
182-
);
183-
expect(out).toContain("Input operator(table shape): upstream(10, 2)");
184-
});
185-
186-
test("sorts multiple input ports by portIndex regardless of input order", () => {
187-
const state = new WorkflowState();
188-
state.addOperator(makeOperator("up0"));
189-
state.addOperator(makeOperator("up1"));
190-
state.addOperator(makeOperator("op1", ["input-0", "input-1"]));
191-
state.addLink(makeLink("l0", ["up0", "output-0"], ["op1", "input-0"]));
192-
state.addLink(makeLink("l1", ["up1", "output-0"], ["op1", "input-1"]));
193-
194-
const out = formatOperatorResult(
195-
"op1",
196-
makeOpInfo({
197-
outputTuples: 1,
198-
result: [{ a: 1 }],
199-
inputPortShapes: [
200-
{ portIndex: 1, rows: 2, columns: 2 },
201-
{ portIndex: 0, rows: 1, columns: 1 },
202-
],
203-
}),
204-
state
205-
);
206-
expect(out).toContain("Input operator(table shape): up0(1, 1), up1(2, 2)");
133+
expect(lines[2]).toBe("WARNING: truncated to 1 row");
134+
expect(lines[3]).toBe("WARNING: something else");
207135
});
208136
});
209137

@@ -221,8 +149,7 @@ describe("formatOperatorResult - visualization rows", () => {
221149
label: "chart",
222150
},
223151
],
224-
}),
225-
EMPTY_STATE
152+
})
226153
);
227154
expect(out).toContain("<skipped: visualization content>");
228155
expect(out).not.toContain("<div>hidden</div>");
@@ -236,8 +163,7 @@ describe("formatOperatorResult - visualization rows", () => {
236163
makeOpInfo({
237164
outputTuples: 1,
238165
result: [{ __is_visualization__: false, "html-content": "<keep/>" }],
239-
}),
240-
EMPTY_STATE
166+
})
241167
);
242168
expect(out).toContain("<keep/>");
243169
expect(out).not.toContain("<skipped: visualization content>");
@@ -249,8 +175,7 @@ describe("formatOperatorResult - visualization rows", () => {
249175
makeOpInfo({
250176
outputTuples: 1,
251177
result: [{ __is_visualization__: false, value: 1 }],
252-
}),
253-
EMPTY_STATE
178+
})
254179
);
255180
const lines = out.split("\n");
256181
expect(out).toContain("Output table shape: (1, 1)");
@@ -262,8 +187,8 @@ describe("formatOperatorResult - visualization rows", () => {
262187
});
263188

264189
describe("jsonToTableFormat - cell coercion via formatOperatorResult", () => {
265-
function tableLines(opInfo: Partial<OperatorInfo>): string[] {
266-
const out = formatOperatorResult("op1", makeOpInfo({ outputTuples: 1, ...opInfo }), EMPTY_STATE);
190+
function tableLines(opInfo: OpInfoOverrides): string[] {
191+
const out = formatOperatorResult("op1", makeOpInfo({ outputTuples: 1, ...opInfo }));
267192
// Skip brief summary + shape line.
268193
return out.split("\n").slice(2);
269194
}
@@ -305,8 +230,7 @@ describe("jsonToTableFormat - row index gaps", () => {
305230
{ __row_index__: 0, v: "a" },
306231
{ __row_index__: 5, v: "b" },
307232
],
308-
}),
309-
EMPTY_STATE
233+
})
310234
);
311235
const lines = out.split("\n");
312236
// header, row0, gap marker, row5
@@ -325,18 +249,13 @@ describe("jsonToTableFormat - row index gaps", () => {
325249
{ __row_index__: 0, v: "a" },
326250
{ __row_index__: 1, v: "b" },
327251
],
328-
}),
329-
EMPTY_STATE
252+
})
330253
);
331254
expect(out).not.toContain("...\t...");
332255
});
333256

334257
test("non-zero starting __row_index__ does not emit a leading gap marker", () => {
335-
const out = formatOperatorResult(
336-
"op1",
337-
makeOpInfo({ outputTuples: 1, result: [{ __row_index__: 9, v: "z" }] }),
338-
EMPTY_STATE
339-
);
258+
const out = formatOperatorResult("op1", makeOpInfo({ outputTuples: 1, result: [{ __row_index__: 9, v: "z" }] }));
340259
expect(out).not.toContain("...\t...");
341260
expect(out.endsWith("9\tz")).toBe(true);
342261
});

0 commit comments

Comments
 (0)